diff --git a/.gitea/workflows/ci-dotnet.yml b/.gitea/workflows/ci-dotnet.yml deleted file mode 100644 index f96c6d7..0000000 --- a/.gitea/workflows/ci-dotnet.yml +++ /dev/null @@ -1,45 +0,0 @@ -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: - runs-on: ubuntu-latest - env: - DOTNET_ROOT: /home/mika/.dotnet - steps: - - name: Checkout - uses: https://github.com/actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Build - run: | - set -euo pipefail - export PATH="$DOTNET_ROOT:$PATH" - dotnet build tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -c Release - - - name: Test - run: | - set -euo pipefail - export PATH="$DOTNET_ROOT:$PATH" - dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj \ - -c Release --no-build --logger "console;verbosity=normal" diff --git a/.gitea/workflows/release-dotnet.yml b/.gitea/workflows/release-dotnet.yml deleted file mode 100644 index d0c3860..0000000 --- a/.gitea/workflows/release-dotnet.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - release: - runs-on: ubuntu-latest - env: - DOTNET_ROOT: /home/mika/.dotnet - GITEA_API: https://git.kuns.dev/api/v1 - REPO: releases/ClaudeMailbox - steps: - - name: Checkout tag - uses: https://github.com/actions/checkout@v4 - with: - fetch-depth: 0 - - - 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" - echo "Building version: $VERSION (tag: $TAG)" - - - name: Publish (win-x64, self-contained, single-file) - env: - VERSION: ${{ steps.ver.outputs.version }} - run: | - set -euo pipefail - export PATH="$DOTNET_ROOT:$PATH" - dotnet publish src/ClaudeMailbox/ClaudeMailbox.csproj \ - -c Release -r win-x64 --self-contained true \ - /p:MinVerVersionOverride=$VERSION \ - /p:PublishSingleFile=true \ - /p:IncludeNativeLibrariesForSelfExtract=true \ - -o out/app - - - name: Package assets - env: - VERSION: ${{ steps.ver.outputs.version }} - run: | - set -euo pipefail - mkdir -p assets - - EXE_SRC=$(ls out/app/*.exe | head -n 1) - if [ -z "$EXE_SRC" ]; then - echo "::error::No .exe produced by publish" >&2 - exit 1 - fi - - EXE_NAME="claude-mailbox-${VERSION}-win-x64.exe" - cp "$EXE_SRC" "assets/${EXE_NAME}" - - ( cd assets && sha256sum "${EXE_NAME}" > checksums.txt ) - - echo "--- assets ---" - ls -la assets - - - name: Create Gitea Release - id: release - env: - TAG: ${{ steps.ver.outputs.tag }} - TOKEN: ${{ secrets.GITEA_TOKEN }} - run: | - set -euo pipefail - 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') - if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then - echo "::error::Release creation failed" >&2 - echo "$RESP" >&2 - exit 1 - fi - echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" - echo "Created release id=$RELEASE_ID for tag=$TAG" - - - name: Upload release assets - env: - VERSION: ${{ steps.ver.outputs.version }} - RELEASE_ID: ${{ steps.release.outputs.release_id }} - TOKEN: ${{ secrets.GITEA_TOKEN }} - run: | - set -euo pipefail - cd assets - for f in \ - "claude-mailbox-${VERSION}-win-x64.exe" \ - "checksums.txt" - do - echo "Uploading: $f" - 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 - echo "All assets uploaded." diff --git a/.gitea/workflows/release-node.yml b/.gitea/workflows/release-node.yml index 1322cc2..1552866 100644 --- a/.gitea/workflows/release-node.yml +++ b/.gitea/workflows/release-node.yml @@ -85,7 +85,7 @@ jobs: TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail - # Try to find an existing release for this tag (the .NET workflow may have created it). + # Try to find an existing release for this tag (idempotent re-runs). EXISTING=$(curl -sS \ -H "Authorization: token ${TOKEN}" \ "${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "") diff --git a/ClaudeMailbox.slnx b/ClaudeMailbox.slnx deleted file mode 100644 index 925baf8..0000000 --- a/ClaudeMailbox.slnx +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 3245073..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,8 +0,0 @@ - - - v - - - - - diff --git a/NuGet.config b/NuGet.config deleted file mode 100644 index 4d736c1..0000000 --- a/NuGet.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/README.md b/README.md index 599b4d1..a48ac32 100644 --- a/README.md +++ b/README.md @@ -85,21 +85,6 @@ Optionally add a static identity (so your client doesn't need to pass `from` / ` "headers": { "X-Mailbox": "backend" } ``` -### C. Build the .NET binary from source - -The original .NET 8 implementation lives in `src/ClaudeMailbox/`. Wire-compatible with the npm build (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema). - -```powershell -dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -``` - -Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell): - -``` -claude-mailbox install-service [--port 37849] [--bind 127.0.0.1] [--db-path ] -claude-mailbox uninstall-service [--purge] -``` - --- ## How identity works @@ -219,7 +204,7 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a | HTTP | | +--------------+-----------------+--------------------------+ v - claude-mailbox serve (npm: Fastify; .NET: Kestrel) + claude-mailbox serve (Fastify) /mcp MCP tools /v1/* REST for non-MCP senders /health @@ -232,19 +217,13 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a ## Development ```sh -# Node port (the recommended runtime) cd node npm install npm run build npm test - -# .NET 8 port (wire-compatible alternative) -dotnet build -dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -dotnet run --project src/ClaudeMailbox -- serve ``` -The test suites cover end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching. +The test suite covers end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching. --- diff --git a/global.json b/global.json deleted file mode 100644 index 1781139..0000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "8.0.418", - "rollForward": "latestFeature" - } -} diff --git a/node/README.md b/node/README.md index 453daf5..849d35f 100644 --- a/node/README.md +++ b/node/README.md @@ -1,6 +1,6 @@ # @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). +Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. ## Install diff --git a/src/ClaudeMailbox/ClaudeMailbox.csproj b/src/ClaudeMailbox/ClaudeMailbox.csproj deleted file mode 100644 index 7bacf8b..0000000 --- a/src/ClaudeMailbox/ClaudeMailbox.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net8.0 - enable - enable - claude-mailbox - ClaudeMailbox - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/src/ClaudeMailbox/Cli/ClientCommands.cs b/src/ClaudeMailbox/Cli/ClientCommands.cs deleted file mode 100644 index 554358d..0000000 --- a/src/ClaudeMailbox/Cli/ClientCommands.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; - -namespace ClaudeMailbox.Cli; - -public static class ClientCommands -{ - private const string DefaultUrl = "http://127.0.0.1:37849"; - - public static async Task RunAsync(string[] args) - { - var command = args[0]; - var url = GetOption(args, "--url") ?? DefaultUrl; - - using var client = new HttpClient { BaseAddress = new Uri(url) }; - - try - { - return command switch - { - "send" => await Send(args, client), - "peek" => await Peek(args, client), - "check" => await Check(args, client), - "list" => await List(client), - _ => PrintError($"Unknown command: {command}"), - }; - } - catch (HttpRequestException ex) - { - Console.Error.WriteLine($"Could not reach daemon at {url}: {ex.Message}"); - Console.Error.WriteLine("Is 'claude-mailbox serve' running?"); - return 2; - } - } - - private static async Task Send(string[] args, HttpClient client) - { - var to = Required(args, "--to"); - var from = Required(args, "--from"); - var body = Required(args, "--body"); - - var req = new HttpRequestMessage(HttpMethod.Post, "/v1/send") - { - Content = JsonContent.Create(new { to, body }), - }; - req.Headers.Add("X-Mailbox", from); - - var res = await client.SendAsync(req); - res.EnsureSuccessStatusCode(); - Console.WriteLine(await res.Content.ReadAsStringAsync()); - return 0; - } - - private static async Task Peek(string[] args, HttpClient client) - { - var name = Required(args, "--name"); - var res = await client.GetAsync($"/v1/peek?name={Uri.EscapeDataString(name)}"); - res.EnsureSuccessStatusCode(); - Console.WriteLine(await res.Content.ReadAsStringAsync()); - return 0; - } - - private static async Task Check(string[] args, HttpClient client) - { - var name = Required(args, "--name"); - - var req = new HttpRequestMessage(HttpMethod.Post, $"/v1/check-inbox?name={Uri.EscapeDataString(name)}"); - req.Headers.Add("X-Mailbox", name); - - var res = await client.SendAsync(req); - res.EnsureSuccessStatusCode(); - Console.WriteLine(await res.Content.ReadAsStringAsync()); - return 0; - } - - private static async Task List(HttpClient client) - { - var res = await client.GetAsync("/v1/list"); - res.EnsureSuccessStatusCode(); - Console.WriteLine(await res.Content.ReadAsStringAsync()); - return 0; - } - - public static string? GetOption(string[] args, string name) - { - for (var i = 0; i < args.Length - 1; i++) - if (args[i] == name) return args[i + 1]; - return null; - } - - private static string Required(string[] args, string name) - { - var v = GetOption(args, name); - if (string.IsNullOrWhiteSpace(v)) - throw new ArgumentException($"Missing required option {name}"); - return v; - } - - private static int PrintError(string msg) - { - Console.Error.WriteLine(msg); - return 1; - } -} diff --git a/src/ClaudeMailbox/Cli/ServiceCommands.cs b/src/ClaudeMailbox/Cli/ServiceCommands.cs deleted file mode 100644 index 04cc6be..0000000 --- a/src/ClaudeMailbox/Cli/ServiceCommands.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System.Diagnostics; -using System.Runtime.Versioning; -using System.Security.AccessControl; -using System.Security.Principal; - -namespace ClaudeMailbox.Cli; - -public static class ServiceCommands -{ - public const string ServiceName = "ClaudeMailbox"; - - public static Task RunAsync(string[] args) - { - if (!OperatingSystem.IsWindows()) - { - Console.Error.WriteLine("Service commands are Windows-only."); - return Task.FromResult(2); - } - - var verb = args[0]; - return verb switch - { - "install-service" => Task.FromResult(InstallService(args)), - "uninstall-service" => Task.FromResult(UninstallService(args)), - "start" => Task.FromResult(RunSc("start", ServiceName)), - "stop" => Task.FromResult(RunSc("stop", ServiceName)), - "status" => Task.FromResult(Status()), - _ => Task.FromResult(PrintError($"Unknown service command: {verb}")), - }; - } - - [SupportedOSPlatform("windows")] - private static bool IsAdministrator() - { - using var identity = WindowsIdentity.GetCurrent(); - return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); - } - - [SupportedOSPlatform("windows")] - private static int RequireAdmin() - { - if (IsAdministrator()) return 0; - Console.Error.WriteLine("This command requires Administrator privileges."); - return 5; - } - - [SupportedOSPlatform("windows")] - private static int InstallService(string[] args) - { - var admin = RequireAdmin(); - if (admin != 0) return admin; - - var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); - var dataDir = Path.Combine(programData, "ClaudeMailbox"); - var configPath = Path.Combine(dataDir, "mailbox.json"); - var defaultDbPath = Path.Combine(dataDir, "mailbox.db"); - - Directory.CreateDirectory(dataDir); - ApplyLocalServiceAcl(dataDir); - - if (!File.Exists(configPath)) - { - var portStr = ClientCommands.GetOption(args, "--port"); - var port = int.TryParse(portStr, out var p) ? p : 37849; - var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1"; - var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath; - - var json = $$""" - { - "port": {{port}}, - "bind": {{System.Text.Json.JsonSerializer.Serialize(bind)}}, - "dbPath": {{System.Text.Json.JsonSerializer.Serialize(dbPath)}} - } - """; - File.WriteAllText(configPath, json); - Console.WriteLine($"Seeded config: {configPath}"); - } - else - { - Console.WriteLine($"Config already exists, leaving untouched: {configPath}"); - } - - var exe = Environment.ProcessPath - ?? throw new InvalidOperationException("Cannot resolve current executable path."); - - var binPath = $"\"{exe}\" serve --config \"{configPath}\""; - - var createExit = RunSc("create", ServiceName, - "binPath=", binPath, - "start=", "auto", - "DisplayName=", "Claude Mailbox", - "obj=", "NT AUTHORITY\\LocalService"); - if (createExit != 0) - { - if (createExit == 1073) - Console.Error.WriteLine($"Service '{ServiceName}' already exists. Run 'claude-mailbox uninstall-service' first."); - else - Console.Error.WriteLine($"sc create failed (exit {createExit})."); - return createExit; - } - - RunSc("description", ServiceName, "MCP mailbox server for parallel Claude sessions"); - - Console.WriteLine($"Service '{ServiceName}' installed. Start with: claude-mailbox start"); - return 0; - } - - [SupportedOSPlatform("windows")] - private static void ApplyLocalServiceAcl(string path) - { - var info = new DirectoryInfo(path); - var security = info.GetAccessControl(); - var localService = new SecurityIdentifier(WellKnownSidType.LocalServiceSid, null); - security.AddAccessRule(new FileSystemAccessRule( - localService, - FileSystemRights.Modify | FileSystemRights.Synchronize, - InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, - PropagationFlags.None, - AccessControlType.Allow)); - info.SetAccessControl(security); - } - - [SupportedOSPlatform("windows")] - private static int UninstallService(string[] args) - { - var admin = RequireAdmin(); - if (admin != 0) return admin; - - var purge = Array.IndexOf(args, "--purge") >= 0; - - // Best-effort stop; ignore failure if not running. - RunSc("stop", ServiceName); - - var deleteExit = RunSc("delete", ServiceName); - if (deleteExit != 0) - { - Console.Error.WriteLine($"sc delete failed (exit {deleteExit})."); - return deleteExit; - } - - if (purge) - { - var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); - var dataDir = Path.Combine(programData, "ClaudeMailbox"); - if (Directory.Exists(dataDir)) - { - Directory.Delete(dataDir, recursive: true); - Console.WriteLine($"Purged: {dataDir}"); - } - } - - Console.WriteLine($"Service '{ServiceName}' uninstalled."); - return 0; - } - - [SupportedOSPlatform("windows")] - private static int Status() - { - var psi = new ProcessStartInfo("sc.exe") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add("query"); - psi.ArgumentList.Add(ServiceName); - - using var proc = Process.Start(psi)!; - var stdout = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(); - - if (proc.ExitCode != 0) - { - Console.WriteLine("NotInstalled"); - return 2; - } - - var state = stdout.Split('\n') - .Select(l => l.Trim()) - .FirstOrDefault(l => l.StartsWith("STATE", StringComparison.Ordinal)) - ?? ""; - - if (state.Contains("RUNNING", StringComparison.Ordinal)) - { - Console.WriteLine("Running"); - return 0; - } - - Console.WriteLine("Stopped"); - return 1; - } - - [SupportedOSPlatform("windows")] - internal static int RunSc(params string[] scArgs) - { - var psi = new ProcessStartInfo("sc.exe") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - foreach (var a in scArgs) psi.ArgumentList.Add(a); - - using var proc = Process.Start(psi)!; - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); - - if (!string.IsNullOrWhiteSpace(stdout)) Console.Write(stdout); - if (!string.IsNullOrWhiteSpace(stderr)) Console.Error.Write(stderr); - return proc.ExitCode; - } - - private static int PrintError(string msg) - { - Console.Error.WriteLine(msg); - return 1; - } -} diff --git a/src/ClaudeMailbox/Config/ConfigResolver.cs b/src/ClaudeMailbox/Config/ConfigResolver.cs deleted file mode 100644 index 7fec2db..0000000 --- a/src/ClaudeMailbox/Config/ConfigResolver.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ClaudeMailbox.Cli; - -namespace ClaudeMailbox.Config; - -public static class ConfigResolver -{ - public static DaemonConfig Build(string[] serveArgs, FileConfig file) - { - var cliPort = ParseIntOption(serveArgs, "--port"); - var cliBind = ClientCommands.GetOption(serveArgs, "--bind"); - var cliDbPath = ClientCommands.GetOption(serveArgs, "--db-path"); - - var port = cliPort ?? file.Port ?? DaemonConfig.DefaultPort; - var bind = cliBind ?? file.Bind ?? DaemonConfig.DefaultBindAddress; - var dbPathRaw = cliDbPath ?? file.DbPath ?? Paths.DefaultDbPath(); - - return new DaemonConfig - { - Port = port, - BindAddress = bind, - DbPath = Paths.Expand(dbPathRaw), - }; - } - - private static int? ParseIntOption(string[] args, string name) - { - var raw = ClientCommands.GetOption(args, name); - return int.TryParse(raw, out var v) ? v : null; - } -} diff --git a/src/ClaudeMailbox/Config/DaemonConfig.cs b/src/ClaudeMailbox/Config/DaemonConfig.cs deleted file mode 100644 index a5fbf75..0000000 --- a/src/ClaudeMailbox/Config/DaemonConfig.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ClaudeMailbox.Config; - -public sealed class DaemonConfig -{ - public const int DefaultPort = 37849; - public const string DefaultBindAddress = "127.0.0.1"; - - public int Port { get; init; } = DefaultPort; - public string BindAddress { get; init; } = DefaultBindAddress; - public string DbPath { get; init; } = Paths.DefaultDbPath(); - - public string BaseUrl => $"http://{BindAddress}:{Port}"; -} diff --git a/src/ClaudeMailbox/Config/FileConfig.cs b/src/ClaudeMailbox/Config/FileConfig.cs deleted file mode 100644 index 2a2535f..0000000 --- a/src/ClaudeMailbox/Config/FileConfig.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ClaudeMailbox.Config; - -public sealed class FileConfig -{ - [JsonPropertyName("port")] - public int? Port { get; set; } - - [JsonPropertyName("bind")] - public string? Bind { get; set; } - - [JsonPropertyName("dbPath")] - public string? DbPath { get; set; } - - private static readonly JsonSerializerOptions Options = new() - { - PropertyNameCaseInsensitive = true, - }; - - public static FileConfig Load(string? explicitPath, string? defaultPath) - { - if (!string.IsNullOrEmpty(explicitPath)) - { - if (!File.Exists(explicitPath)) - throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath); - return Parse(File.ReadAllText(explicitPath)); - } - - if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath)) - return Parse(File.ReadAllText(defaultPath)); - - return new FileConfig(); - } - - private static FileConfig Parse(string json) - { - return JsonSerializer.Deserialize(json, Options) ?? new FileConfig(); - } -} diff --git a/src/ClaudeMailbox/Config/Paths.cs b/src/ClaudeMailbox/Config/Paths.cs deleted file mode 100644 index 9640ffd..0000000 --- a/src/ClaudeMailbox/Config/Paths.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ClaudeMailbox.Config; - -public static class Paths -{ - public static string Expand(string path) - { - if (string.IsNullOrWhiteSpace(path)) return path; - - var expanded = Environment.ExpandEnvironmentVariables(path); - if (expanded.StartsWith("~")) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - expanded = home + expanded[1..]; - } - return Path.GetFullPath(expanded); - } - - public static string DefaultDbPath() - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Combine(home, ".claude-mailbox", "mailbox.db"); - } -} diff --git a/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs b/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs deleted file mode 100644 index 0e108cb..0000000 --- a/src/ClaudeMailbox/Data/Configuration/MailboxConfiguration.cs +++ /dev/null @@ -1,18 +0,0 @@ -using ClaudeMailbox.Data.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace ClaudeMailbox.Data.Configuration; - -public sealed class MailboxConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("mailboxes"); - - builder.HasKey(m => m.Name); - builder.Property(m => m.Name).HasColumnName("name").IsRequired(); - builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired(); - builder.Property(m => m.LastSeenAt).HasColumnName("last_seen_at").IsRequired(); - } -} diff --git a/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs b/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs deleted file mode 100644 index 6f68463..0000000 --- a/src/ClaudeMailbox/Data/Configuration/MessageConfiguration.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ClaudeMailbox.Data.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace ClaudeMailbox.Data.Configuration; - -public sealed class MessageConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("messages"); - - builder.HasKey(m => m.Id); - builder.Property(m => m.Id).HasColumnName("id").ValueGeneratedOnAdd(); - builder.Property(m => m.ToMailbox).HasColumnName("to_mailbox").IsRequired(); - builder.Property(m => m.FromMailbox).HasColumnName("from_mailbox").IsRequired(); - builder.Property(m => m.Body).HasColumnName("body").IsRequired(); - builder.Property(m => m.CreatedAt).HasColumnName("created_at").IsRequired(); - builder.Property(m => m.DeliveredAt).HasColumnName("delivered_at"); - - builder.HasOne() - .WithMany() - .HasForeignKey(m => m.ToMailbox) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasOne() - .WithMany() - .HasForeignKey(m => m.FromMailbox) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(m => new { m.ToMailbox, m.DeliveredAt }) - .HasDatabaseName("ix_messages_to_delivered"); - } -} diff --git a/src/ClaudeMailbox/Data/MailboxDbContext.cs b/src/ClaudeMailbox/Data/MailboxDbContext.cs deleted file mode 100644 index 69c982b..0000000 --- a/src/ClaudeMailbox/Data/MailboxDbContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ClaudeMailbox.Data.Models; -using Microsoft.EntityFrameworkCore; - -namespace ClaudeMailbox.Data; - -public class MailboxDbContext : DbContext -{ - public MailboxDbContext(DbContextOptions options) : base(options) { } - - public DbSet Mailboxes => Set(); - public DbSet Messages => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(MailboxDbContext).Assembly); - } - - public static void EnsureReady(MailboxDbContext db) - { - var dir = Path.GetDirectoryName(db.Database.GetDbConnection().DataSource); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - - var conn = db.Database.GetDbConnection(); - conn.Open(); - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = "PRAGMA journal_mode=WAL;"; - cmd.ExecuteNonQuery(); - } - - db.Database.EnsureCreated(); - } -} diff --git a/src/ClaudeMailbox/Data/Models/Mailbox.cs b/src/ClaudeMailbox/Data/Models/Mailbox.cs deleted file mode 100644 index e70a38a..0000000 --- a/src/ClaudeMailbox/Data/Models/Mailbox.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ClaudeMailbox.Data.Models; - -public sealed class Mailbox -{ - public required string Name { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime LastSeenAt { get; set; } -} diff --git a/src/ClaudeMailbox/Data/Models/Message.cs b/src/ClaudeMailbox/Data/Models/Message.cs deleted file mode 100644 index 020dcc8..0000000 --- a/src/ClaudeMailbox/Data/Models/Message.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ClaudeMailbox.Data.Models; - -public sealed class Message -{ - public long Id { get; set; } - public required string ToMailbox { get; set; } - public required string FromMailbox { get; set; } - public required string Body { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime? DeliveredAt { get; set; } -} diff --git a/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs b/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs deleted file mode 100644 index 510acd9..0000000 --- a/src/ClaudeMailbox/Data/Repositories/MailboxRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ClaudeMailbox.Data.Models; -using Microsoft.EntityFrameworkCore; - -namespace ClaudeMailbox.Data.Repositories; - -public sealed class MailboxRepository -{ - private readonly MailboxDbContext _db; - - public MailboxRepository(MailboxDbContext db) => _db = db; - - public async Task UpsertAsync(string name, CancellationToken ct = default) - { - var now = DateTime.UtcNow; - var row = await _db.Mailboxes.FirstOrDefaultAsync(m => m.Name == name, ct); - if (row is null) - { - row = new Mailbox { Name = name, CreatedAt = now, LastSeenAt = now }; - _db.Mailboxes.Add(row); - } - else - { - row.LastSeenAt = now; - } - await _db.SaveChangesAsync(ct); - return row; - } - - public async Task> ListAsync(CancellationToken ct = default) - { - return await _db.Mailboxes.AsNoTracking().OrderBy(m => m.Name).ToListAsync(ct); - } -} diff --git a/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs b/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs deleted file mode 100644 index 05a0a1a..0000000 --- a/src/ClaudeMailbox/Data/Repositories/MessageRepository.cs +++ /dev/null @@ -1,77 +0,0 @@ -using ClaudeMailbox.Data.Models; -using Microsoft.EntityFrameworkCore; - -namespace ClaudeMailbox.Data.Repositories; - -public sealed class MessageRepository -{ - private readonly MailboxDbContext _db; - private readonly MailboxRepository _mailboxes; - - public MessageRepository(MailboxDbContext db, MailboxRepository mailboxes) - { - _db = db; - _mailboxes = mailboxes; - } - - public async Task SendAsync(string from, string to, string body, CancellationToken ct = default) - { - await _mailboxes.UpsertAsync(from, ct); - await _mailboxes.UpsertAsync(to, ct); - - var message = new Message - { - FromMailbox = from, - ToMailbox = to, - Body = body, - CreatedAt = DateTime.UtcNow, - DeliveredAt = null, - }; - _db.Messages.Add(message); - await _db.SaveChangesAsync(ct); - return message; - } - - public async Task PeekAsync(string name, CancellationToken ct = default) - { - var pending = await _db.Messages.AsNoTracking() - .Where(m => m.ToMailbox == name && m.DeliveredAt == null) - .OrderBy(m => m.Id) - .Select(m => m.CreatedAt) - .ToListAsync(ct); - - return new InboxStatus(pending.Count, pending.FirstOrDefault() == default ? null : pending.First()); - } - - public async Task> CheckInboxAsync(string name, CancellationToken ct = default) - { - // Atomic pull-and-mark: a transaction guarantees that two concurrent calls - // don't deliver the same message twice. - await using var tx = await _db.Database.BeginTransactionAsync(ct); - - var pending = await _db.Messages - .Where(m => m.ToMailbox == name && m.DeliveredAt == null) - .OrderBy(m => m.Id) - .ToListAsync(ct); - - var now = DateTime.UtcNow; - foreach (var m in pending) - m.DeliveredAt = now; - - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - - return pending; - } - - public async Task PendingCountForAsync(string recipient, string sender, CancellationToken ct = default) - { - return await _db.Messages.AsNoTracking() - .CountAsync(m => - m.ToMailbox == recipient && - m.FromMailbox == sender && - m.DeliveredAt == null, ct); - } -} - -public sealed record InboxStatus(int Pending, DateTime? OldestAt); diff --git a/src/ClaudeMailbox/Http/MailboxContextAccessor.cs b/src/ClaudeMailbox/Http/MailboxContextAccessor.cs deleted file mode 100644 index c152d9d..0000000 --- a/src/ClaudeMailbox/Http/MailboxContextAccessor.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace ClaudeMailbox.Http; - -public sealed class MailboxContextAccessor -{ - private readonly IHttpContextAccessor _http; - - public MailboxContextAccessor(IHttpContextAccessor http) => _http = http; - - public string Current - { - get - { - var name = _http.HttpContext?.Items[MailboxHeaderMiddleware.ItemsKey] as string; - if (string.IsNullOrWhiteSpace(name)) - throw new InvalidOperationException( - "No mailbox name on request. Set the X-Mailbox header in your .mcp.json."); - return name; - } - } -} diff --git a/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs b/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs deleted file mode 100644 index afa71e6..0000000 --- a/src/ClaudeMailbox/Http/MailboxHeaderMiddleware.cs +++ /dev/null @@ -1,48 +0,0 @@ -using ClaudeMailbox.Data.Repositories; -using Microsoft.AspNetCore.Http; - -namespace ClaudeMailbox.Http; - -public sealed class MailboxHeaderMiddleware -{ - public const string HeaderName = "X-Mailbox"; - public const string ItemsKey = "Mailbox"; - - private readonly RequestDelegate _next; - - public MailboxHeaderMiddleware(RequestDelegate next) => _next = next; - - public async Task InvokeAsync(HttpContext ctx, MailboxRepository mailboxes) - { - // Health is always anonymous. - if (ctx.Request.Path.StartsWithSegments("/health")) - { - await _next(ctx); - return; - } - - var name = ctx.Request.Headers[HeaderName].ToString().Trim(); - - // These endpoints work without identity (discovery / read-only status). - var path = ctx.Request.Path; - var isAnonymous = - path.Equals("/v1/list", StringComparison.OrdinalIgnoreCase) || - path.Equals("/v1/peek", StringComparison.OrdinalIgnoreCase); - - if (string.IsNullOrWhiteSpace(name)) - { - if (isAnonymous) - { - await _next(ctx); - return; - } - ctx.Response.StatusCode = 400; - await ctx.Response.WriteAsync($"Missing {HeaderName} header."); - return; - } - - ctx.Items[ItemsKey] = name; - await mailboxes.UpsertAsync(name, ctx.RequestAborted); - await _next(ctx); - } -} diff --git a/src/ClaudeMailbox/Http/RestEndpoints.cs b/src/ClaudeMailbox/Http/RestEndpoints.cs deleted file mode 100644 index 80509ea..0000000 --- a/src/ClaudeMailbox/Http/RestEndpoints.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Reflection; -using ClaudeMailbox.Config; -using ClaudeMailbox.Data.Repositories; - -namespace ClaudeMailbox.Http; - -public static class RestEndpoints -{ - public static void MapMailboxEndpoints(this WebApplication app) - { - app.MapGet("/health", (DaemonConfig cfg) => Results.Ok(new - { - status = "ok", - version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown", - dbPath = cfg.DbPath, - })); - - var group = app.MapGroup("/v1"); - - group.MapPost("/send", async ( - SendRequest body, - MailboxContextAccessor accessor, - MessageRepository messages, - CancellationToken ct) => - { - if (string.IsNullOrWhiteSpace(body.To) || string.IsNullOrWhiteSpace(body.Body)) - return Results.BadRequest(new { error = "to and body are required" }); - - var from = accessor.Current; - var msg = await messages.SendAsync(from, body.To, body.Body, ct); - return Results.Ok(new { id = msg.Id, queuedAt = msg.CreatedAt }); - }); - - group.MapGet("/peek", async ( - string name, - MessageRepository messages, - CancellationToken ct) => - { - var status = await messages.PeekAsync(name, ct); - return Results.Ok(new { pending = status.Pending, oldestAt = status.OldestAt }); - }); - - group.MapPost("/check-inbox", async ( - string name, - MailboxContextAccessor accessor, - MessageRepository messages, - CancellationToken ct) => - { - // Require the caller to be consuming their own inbox. - if (!string.Equals(name, accessor.Current, StringComparison.Ordinal)) - return Results.StatusCode(403); - - var pulled = await messages.CheckInboxAsync(name, ct); - return Results.Ok(pulled.Select(m => new - { - id = m.Id, - from = m.FromMailbox, - body = m.Body, - sentAt = m.CreatedAt, - })); - }); - - group.MapGet("/list", async ( - MailboxRepository mailboxes, - CancellationToken ct) => - { - var all = await mailboxes.ListAsync(ct); - return Results.Ok(all.Select(m => new - { - name = m.Name, - createdAt = m.CreatedAt, - lastSeenAt = m.LastSeenAt, - })); - }); - } - - public sealed record SendRequest(string To, string Body); -} diff --git a/src/ClaudeMailbox/Mcp/MailboxTools.cs b/src/ClaudeMailbox/Mcp/MailboxTools.cs deleted file mode 100644 index 443deb0..0000000 --- a/src/ClaudeMailbox/Mcp/MailboxTools.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.ComponentModel; -using ClaudeMailbox.Data.Repositories; -using ClaudeMailbox.Http; -using ModelContextProtocol.Server; - -namespace ClaudeMailbox.Mcp; - -public sealed record SendResult(long Id, DateTime QueuedAt); -public sealed record InboxMessage(long Id, string From, string Body, DateTime SentAt); -public sealed record InboxStatusDto(int Pending, DateTime? OldestAt); -public sealed record MailboxInfo(string Name, DateTime LastSeenAt, int PendingForYou); - -[McpServerToolType] -public sealed class MailboxTools -{ - private readonly MailboxContextAccessor _accessor; - private readonly MailboxRepository _mailboxes; - private readonly MessageRepository _messages; - - public MailboxTools( - MailboxContextAccessor accessor, - MailboxRepository mailboxes, - MessageRepository messages) - { - _accessor = accessor; - _mailboxes = mailboxes; - _messages = messages; - } - - [McpServerTool, Description("Send a message to another mailbox. The sender is the current session's X-Mailbox name.")] - public async Task Send( - [Description("Name of the recipient mailbox.")] string to, - [Description("Message body (plain text or markdown).")] string body, - CancellationToken ct) - { - var from = _accessor.Current; - var msg = await _messages.SendAsync(from, to, body, ct); - return new SendResult(msg.Id, msg.CreatedAt); - } - - [McpServerTool, Description("Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.")] - public async Task> CheckInbox(CancellationToken ct) - { - var name = _accessor.Current; - var pulled = await _messages.CheckInboxAsync(name, ct); - return pulled.Select(m => new InboxMessage(m.Id, m.FromMailbox, m.Body, m.CreatedAt)).ToList(); - } - - [McpServerTool, Description("Check whether the current mailbox has undelivered messages, without consuming them. Cheap; safe to call often.")] - public async Task PeekInbox(CancellationToken ct) - { - var name = _accessor.Current; - var status = await _messages.PeekAsync(name, ct); - return new InboxStatusDto(status.Pending, status.OldestAt); - } - - [McpServerTool, Description("List all known mailboxes with their last-seen timestamp and how many messages each has queued for the current mailbox.")] - public async Task> ListMailboxes(CancellationToken ct) - { - var me = _accessor.Current; - var all = await _mailboxes.ListAsync(ct); - var result = new List(all.Count); - foreach (var m in all) - { - var pending = await _messages.PendingCountForAsync(me, m.Name, ct); - result.Add(new MailboxInfo(m.Name, m.LastSeenAt, pending)); - } - return result; - } -} diff --git a/src/ClaudeMailbox/Program.cs b/src/ClaudeMailbox/Program.cs deleted file mode 100644 index 048528c..0000000 --- a/src/ClaudeMailbox/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ClaudeMailbox; -using ClaudeMailbox.Cli; -using ClaudeMailbox.Config; - -if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list") -{ - return await ClientCommands.RunAsync(args); -} - -if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status") -{ - return await ServiceCommands.RunAsync(args); -} - -var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args; - -var explicitConfig = ClientCommands.GetOption(serveArgs, "--config"); -var defaultConfig = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "ClaudeMailbox", "mailbox.json"); - -var fileConfig = FileConfig.Load(explicitConfig, defaultConfig); -var cfg = ConfigResolver.Build(serveArgs, fileConfig); - -var builder = ServerHost.CreateBuilder(cfg, serveArgs); -builder.WebHost.UseUrls(cfg.BaseUrl); - -var app = builder.Build(); -ServerHost.ConfigurePipeline(app); - -app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath); - -try -{ - await app.RunAsync(); - return 0; -} -catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) - || ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase)) -{ - Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running."); - return 3; -} - -public partial class Program { } diff --git a/src/ClaudeMailbox/ServerHost.cs b/src/ClaudeMailbox/ServerHost.cs deleted file mode 100644 index c9a5ced..0000000 --- a/src/ClaudeMailbox/ServerHost.cs +++ /dev/null @@ -1,48 +0,0 @@ -using ClaudeMailbox.Config; -using ClaudeMailbox.Data; -using ClaudeMailbox.Data.Repositories; -using ClaudeMailbox.Http; -using ClaudeMailbox.Mcp; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting.WindowsServices; - -namespace ClaudeMailbox; - -public static class ServerHost -{ - public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null) - { - var builder = WebApplication.CreateBuilder(args ?? Array.Empty()); - builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox"); - - builder.Services.AddSingleton(cfg); - builder.Services.AddHttpContextAccessor(); - - builder.Services.AddDbContext(opt => - opt.UseSqlite($"Data Source={cfg.DbPath}")); - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - builder.Services.AddMcpServer() - .WithHttpTransport() - .WithTools(); - - return builder; - } - - public static void ConfigurePipeline(WebApplication app) - { - using (var scope = app.Services.CreateScope()) - { - MailboxDbContext.EnsureReady( - scope.ServiceProvider.GetRequiredService()); - } - - app.UseMiddleware(); - app.MapMailboxEndpoints(); - app.MapMcp("/mcp"); - } -} diff --git a/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj b/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj deleted file mode 100644 index e1c613f..0000000 --- a/tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net8.0 - enable - enable - - false - true - - - - - - - - - - - - - - - - - - - - diff --git a/tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs b/tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs deleted file mode 100644 index 69ee9a0..0000000 --- a/tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ClaudeMailbox.Config; - -namespace ClaudeMailbox.Tests.Config; - -public sealed class ConfigResolverTests -{ - [Fact] - public void CliFlag_WinsOverFile() - { - var file = new FileConfig { Port = 1000 }; - var cfg = ConfigResolver.Build(new[] { "--port", "9999" }, file); - Assert.Equal(9999, cfg.Port); - } - - [Fact] - public void File_WinsOverDefault() - { - var file = new FileConfig { Port = 1000, Bind = "0.0.0.0", DbPath = "/tmp/x.db" }; - var cfg = ConfigResolver.Build(Array.Empty(), file); - Assert.Equal(1000, cfg.Port); - Assert.Equal("0.0.0.0", cfg.BindAddress); - Assert.Equal(Paths.Expand("/tmp/x.db"), cfg.DbPath); - } - - [Fact] - public void Default_UsedWhenNeitherCliNorFile() - { - var cfg = ConfigResolver.Build(Array.Empty(), new FileConfig()); - Assert.Equal(DaemonConfig.DefaultPort, cfg.Port); - Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress); - Assert.Equal(Paths.DefaultDbPath(), cfg.DbPath); - } - - [Fact] - public void Mixed_CliPort_FileDbPath_DefaultBind() - { - var file = new FileConfig { DbPath = "/tmp/mixed.db" }; - var cfg = ConfigResolver.Build(new[] { "--port", "7000" }, file); - Assert.Equal(7000, cfg.Port); - Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress); - Assert.Equal(Paths.Expand("/tmp/mixed.db"), cfg.DbPath); - } - - [Fact] - public void CliDbPath_ExpandsEnvVars() - { - var file = new FileConfig(); - var cfg = ConfigResolver.Build(new[] { "--db-path", "~/foo.db" }, file); - Assert.DoesNotContain("~", cfg.DbPath); - } - - [Fact] - public void InvalidPortFlag_FallsBackToFileOrDefault() - { - var file = new FileConfig { Port = 4242 }; - var cfg = ConfigResolver.Build(new[] { "--port", "not-a-number" }, file); - Assert.Equal(4242, cfg.Port); - } -} diff --git a/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs b/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs deleted file mode 100644 index 56ef39b..0000000 --- a/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using ClaudeMailbox.Config; - -namespace ClaudeMailbox.Tests.Config; - -public sealed class FileConfigTests -{ - [Fact] - public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing() - { - var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json"); - var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing); - - Assert.Null(cfg.Port); - Assert.Null(cfg.Bind); - Assert.Null(cfg.DbPath); - } - - [Fact] - public void Load_ReadsDefaultPath_WhenExplicitPathNull() - { - var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}"""); - try - { - var cfg = FileConfig.Load(explicitPath: null, defaultPath: path); - Assert.Equal(9000, cfg.Port); - Assert.Equal("0.0.0.0", cfg.Bind); - Assert.Equal(@"C:\tmp\a.db", cfg.DbPath); - } - finally { File.Delete(path); } - } - - [Fact] - public void Load_ExplicitPath_WinsOverDefault() - { - var defaultPath = WriteTemp("""{"port":1111}"""); - var explicitPath = WriteTemp("""{"port":2222}"""); - try - { - var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath); - Assert.Equal(2222, cfg.Port); - } - finally { File.Delete(defaultPath); File.Delete(explicitPath); } - } - - [Fact] - public void Load_ExplicitPathMissing_Throws() - { - var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json"); - var ex = Assert.Throws(() => - FileConfig.Load(explicitPath: missing, defaultPath: null)); - Assert.Contains(missing, ex.Message); - } - - [Fact] - public void Load_MissingFields_AreNull() - { - var path = WriteTemp("""{"port":1234}"""); - try - { - var cfg = FileConfig.Load(explicitPath: path, defaultPath: null); - Assert.Equal(1234, cfg.Port); - Assert.Null(cfg.Bind); - Assert.Null(cfg.DbPath); - } - finally { File.Delete(path); } - } - - [Fact] - public void Load_CaseInsensitive_PropertyNames() - { - var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}"""); - try - { - var cfg = FileConfig.Load(explicitPath: path, defaultPath: null); - Assert.Equal(1, cfg.Port); - Assert.Equal("x", cfg.Bind); - Assert.Equal("y", cfg.DbPath); - } - finally { File.Delete(path); } - } - - [Fact] - public void Load_MalformedJson_Throws() - { - var path = WriteTemp("not json"); - try - { - Assert.ThrowsAny(() => FileConfig.Load(explicitPath: path, defaultPath: null)); - } - finally { File.Delete(path); } - } - - private static string WriteTemp(string content) - { - var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json"); - File.WriteAllText(p, content); - return p; - } -} diff --git a/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs b/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs deleted file mode 100644 index 264e318..0000000 --- a/tests/ClaudeMailbox.Tests/MailboxEndToEndTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; - -namespace ClaudeMailbox.Tests; - -public sealed class MailboxEndToEndTests -{ - [Fact] - public async Task Health_Returns_Ok() - { - await using var host = await TestHost.StartAsync(); - - var res = await host.Client.GetAsync("/health"); - res.EnsureSuccessStatusCode(); - - var body = await res.Content.ReadFromJsonAsync(); - Assert.Equal("ok", body.GetProperty("status").GetString()); - } - - [Fact] - public async Task Send_Without_Header_Is_BadRequest() - { - await using var host = await TestHost.StartAsync(); - - var res = await host.Client.PostAsJsonAsync("/v1/send", new { to = "anyone", body = "hi" }); - Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); - } - - [Fact] - public async Task Two_Mailboxes_Coordinate() - { - await using var host = await TestHost.StartAsync(); - using var backend = host.NewClientFor("backend"); - using var frontend = host.NewClientFor("frontend"); - - // backend sends to frontend - var send = await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "API shape changed" }); - send.EnsureSuccessStatusCode(); - - // frontend peeks — expects 1 - var peek1 = await frontend.GetFromJsonAsync("/v1/peek?name=frontend"); - Assert.Equal(1, peek1.GetProperty("pending").GetInt32()); - - // frontend consumes - var check = await frontend.PostAsync("/v1/check-inbox?name=frontend", null); - check.EnsureSuccessStatusCode(); - var messages = await check.Content.ReadFromJsonAsync(); - Assert.Equal(1, messages.GetArrayLength()); - var msg = messages[0]; - Assert.Equal("backend", msg.GetProperty("from").GetString()); - Assert.Equal("API shape changed", msg.GetProperty("body").GetString()); - - // peek again — expects 0 - var peek2 = await frontend.GetFromJsonAsync("/v1/peek?name=frontend"); - Assert.Equal(0, peek2.GetProperty("pending").GetInt32()); - } - - [Fact] - public async Task Check_Inbox_Rejects_Mismatched_Identity() - { - await using var host = await TestHost.StartAsync(); - using var backend = host.NewClientFor("backend"); - using var frontend = host.NewClientFor("frontend"); - - await backend.PostAsJsonAsync("/v1/send", new { to = "frontend", body = "hello" }); - - // backend tries to consume frontend's inbox — must be rejected - var bad = await backend.PostAsync("/v1/check-inbox?name=frontend", null); - Assert.Equal(HttpStatusCode.Forbidden, bad.StatusCode); - } - - [Fact] - public async Task List_Returns_Known_Mailboxes() - { - await using var host = await TestHost.StartAsync(); - using var a = host.NewClientFor("alpha"); - using var b = host.NewClientFor("beta"); - - // Touch both mailboxes by having each peek its own inbox - await a.GetAsync("/v1/peek?name=alpha"); - await b.GetAsync("/v1/peek?name=beta"); - - // /v1/list is the only endpoint that works without X-Mailbox - var list = await host.Client.GetFromJsonAsync("/v1/list"); - var names = new List(); - foreach (var elem in list.EnumerateArray()) - names.Add(elem.GetProperty("name").GetString()!); - - Assert.Contains("alpha", names); - Assert.Contains("beta", names); - } -} diff --git a/tests/ClaudeMailbox.Tests/MigrationTests.cs b/tests/ClaudeMailbox.Tests/MigrationTests.cs deleted file mode 100644 index 58fb7fa..0000000 --- a/tests/ClaudeMailbox.Tests/MigrationTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using ClaudeMailbox.Data; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace ClaudeMailbox.Tests; - -public sealed class MigrationTests -{ - [Fact] - public async Task EnsureReady_Creates_Schema_And_Is_Idempotent() - { - var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-migtest-{Guid.NewGuid():N}.db"); - try - { - using (var ctx = NewCtx(dbPath)) - MailboxDbContext.EnsureReady(ctx); - - // Second call must not throw. - using (var ctx = NewCtx(dbPath)) - MailboxDbContext.EnsureReady(ctx); - - // Verify tables exist. - await using var conn = new SqliteConnection($"Data Source={dbPath}"); - await conn.OpenAsync(); - - var tables = new List(); - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; - await using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - tables.Add(reader.GetString(0)); - } - Assert.Contains("mailboxes", tables); - Assert.Contains("messages", tables); - - // Verify the expected index exists. - string? index; - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='index' AND name='ix_messages_to_delivered';"; - index = await cmd.ExecuteScalarAsync() as string; - } - Assert.Equal("ix_messages_to_delivered", index); - } - finally - { - SqliteConnection.ClearAllPools(); - foreach (var ext in new[] { "", "-wal", "-shm" }) - { - var p = dbPath + ext; - if (File.Exists(p)) - { - try { File.Delete(p); } catch { } - } - } - } - } - - private static MailboxDbContext NewCtx(string path) - { - var opts = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={path}") - .Options; - return new MailboxDbContext(opts); - } -} diff --git a/tests/ClaudeMailbox.Tests/RaceTests.cs b/tests/ClaudeMailbox.Tests/RaceTests.cs deleted file mode 100644 index b1455d2..0000000 --- a/tests/ClaudeMailbox.Tests/RaceTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; - -namespace ClaudeMailbox.Tests; - -public sealed class RaceTests -{ - [Fact] - public async Task Parallel_CheckInbox_Delivers_Each_Message_Exactly_Once() - { - await using var host = await TestHost.StartAsync(); - using var sender = host.NewClientFor("sender"); - using var recipient = host.NewClientFor("recipient"); - - const int messageCount = 50; - for (var i = 0; i < messageCount; i++) - { - var res = await sender.PostAsJsonAsync("/v1/send", new { to = "recipient", body = $"msg-{i}" }); - res.EnsureSuccessStatusCode(); - } - - // Fire multiple concurrent checks. Each message must appear in exactly one result set. - var tasks = Enumerable.Range(0, 8).Select(async _ => - { - var res = await recipient.PostAsync("/v1/check-inbox?name=recipient", null); - res.EnsureSuccessStatusCode(); - return await res.Content.ReadFromJsonAsync(); - }); - - var results = await Task.WhenAll(tasks); - - var ids = new List(); - foreach (var arr in results) - foreach (var m in arr.EnumerateArray()) - ids.Add(m.GetProperty("id").GetInt64()); - - Assert.Equal(messageCount, ids.Count); - Assert.Equal(messageCount, ids.Distinct().Count()); - } -} diff --git a/tests/ClaudeMailbox.Tests/TestHost.cs b/tests/ClaudeMailbox.Tests/TestHost.cs deleted file mode 100644 index d90a45e..0000000 --- a/tests/ClaudeMailbox.Tests/TestHost.cs +++ /dev/null @@ -1,84 +0,0 @@ -using ClaudeMailbox; -using ClaudeMailbox.Config; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace ClaudeMailbox.Tests; - -/// -/// Spins up a full ClaudeMailbox WebApplication on an ephemeral port against a temp SQLite file. -/// Disposable — removes the DB and stops the host on dispose. -/// -public sealed class TestHost : IAsyncDisposable -{ - private readonly WebApplication _app; - private readonly string _dbPath; - - public HttpClient Client { get; } - public string BaseUrl { get; } - public string DbPath => _dbPath; - - private TestHost(WebApplication app, string dbPath, string baseUrl) - { - _app = app; - _dbPath = dbPath; - BaseUrl = baseUrl; - Client = new HttpClient { BaseAddress = new Uri(baseUrl) }; - } - - public static async Task StartAsync() - { - var dbPath = Path.Combine(Path.GetTempPath(), $"claude-mailbox-test-{Guid.NewGuid():N}.db"); - var cfg = new DaemonConfig - { - Port = 0, - BindAddress = "127.0.0.1", - DbPath = dbPath, - }; - - var builder = ServerHost.CreateBuilder(cfg); - builder.WebHost.UseUrls("http://127.0.0.1:0"); - - var app = builder.Build(); - ServerHost.ConfigurePipeline(app); - - await app.StartAsync(); - - // Discover the port Kestrel picked. - var server = app.Services.GetRequiredService(); - var feature = server.Features.Get(); - var url = feature?.Addresses.FirstOrDefault() - ?? throw new InvalidOperationException("No bound URL after start."); - - return new TestHost(app, dbPath, url); - } - - public HttpClient NewClientFor(string mailboxName) - { - var c = new HttpClient { BaseAddress = new Uri(BaseUrl) }; - c.DefaultRequestHeaders.Add("X-Mailbox", mailboxName); - return c; - } - - public async ValueTask DisposeAsync() - { - Client.Dispose(); - await _app.StopAsync(); - await _app.DisposeAsync(); - - // Allow SQLite handle to release before deleting. - GC.Collect(); - GC.WaitForPendingFinalizers(); - - foreach (var ext in new[] { "", "-wal", "-shm" }) - { - var path = _dbPath + ext; - if (File.Exists(path)) - { - try { File.Delete(path); } catch { /* best-effort cleanup */ } - } - } - } -}