4 Commits

Author SHA1 Message Date
Mika Kuns
2cadc3a867 chore(release): 1.5.0
All checks were successful
CI (Node) / build-test (push) Successful in 8s
Release / release (push) Successful in 8s
Release (Node) / release (push) Successful in 12s
2026-05-20 13:54:43 +02:00
Mika Kuns
0c06e2cf4b feat(cleanup): hide and prune stale mailboxes
Mailbox listings grew unbounded as old sessions ended without
unregistering. This adds two layers of cleanup, configurable via
mailbox.json or `serve` flags:

- Lazy filter: list responses (REST /v1/list, MCP list_mailboxes)
  drop mailboxes idle longer than hideAfterMinutes (default 24h),
  while always keeping the caller and any sender with messages
  pending for them.
- Background sweep: startServer runs an initial prune on boot and
  schedules an unref'd interval timer that hard-deletes mailboxes
  idle longer than deleteAfterMinutes (default 7d) which have no
  pending messages, and wipes their delivered history.
2026-05-20 13:54:03 +02:00
Mika Kuns
06a2ea6b7b chore(release): 1.4.1
All checks were successful
Release / release (push) Successful in 7s
CI (Node) / build-test (push) Successful in 8s
Release (Node) / release (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:34:34 +02:00
Mika Kuns
01c22ff9a3 fix(cli): lazy-load server module so non-serve commands skip node:sqlite
All checks were successful
CI (Node) / build-test (push) Successful in 8s
Importing server.js statically also imports db.ts, which pulls in
node:sqlite at startup. On Linux that emits an ExperimentalWarning to
stderr for every CLI invocation -- visible to users running the hook on
every prompt. Defer the server import into the serve action so check
--hook / session-announce / send / peek / list never touch sqlite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:30:04 +02:00
10 changed files with 354 additions and 22 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "@kuns/claude-mailbox", "name": "@kuns/claude-mailbox",
"version": "1.4.0", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@kuns/claude-mailbox", "name": "@kuns/claude-mailbox",
"version": "1.4.0", "version": "1.5.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kuns/claude-mailbox", "name": "@kuns/claude-mailbox",
"version": "1.4.0", "version": "1.5.0",
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.", "description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
"type": "module", "type": "module",
"bin": { "bin": {

View File

@@ -4,7 +4,6 @@ import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js"; import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
import { startServer } from "./server.js";
import { autostartManager } from "./autostart/index.js"; import { autostartManager } from "./autostart/index.js";
import { runStdioMcp } from "./mcp-stdio.js"; import { runStdioMcp } from "./mcp-stdio.js";
import { import {
@@ -79,9 +78,33 @@ program
.option("--bind <address>", "Bind address") .option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path") .option("--db-path <path>", "SQLite database path")
.option("--config <path>", "Path to mailbox.json") .option("--config <path>", "Path to mailbox.json")
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => { .option(
"--hide-after-minutes <n>",
"Hide mailboxes idle longer than N minutes from list responses (0 = disabled)",
(v) => parseInt(v, 10),
)
.option(
"--delete-after-minutes <n>",
"Hard-delete mailboxes idle longer than N minutes (0 = disabled)",
(v) => parseInt(v, 10),
)
.option(
"--sweep-interval-minutes <n>",
"Stale-mailbox sweep interval in minutes (0 = disabled)",
(v) => parseInt(v, 10),
)
.action(async (opts: {
port?: number;
bind?: string;
dbPath?: string;
config?: string;
hideAfterMinutes?: number;
deleteAfterMinutes?: number;
sweepIntervalMinutes?: number;
}) => {
const cfg = resolveConfig(opts); const cfg = resolveConfig(opts);
try { try {
const { startServer } = await import("./server.js");
const { app } = await startServer(cfg); const { app } = await startServer(cfg);
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`); app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
} catch (err) { } catch (err) {

View File

@@ -4,17 +4,26 @@ import { join, resolve } from "node:path";
export const DEFAULT_PORT = 37849; export const DEFAULT_PORT = 37849;
export const DEFAULT_BIND = "127.0.0.1"; export const DEFAULT_BIND = "127.0.0.1";
export const DEFAULT_HIDE_AFTER_MINUTES = 60 * 24;
export const DEFAULT_DELETE_AFTER_MINUTES = 60 * 24 * 7;
export const DEFAULT_SWEEP_INTERVAL_MINUTES = 60;
export interface FileConfig { export interface FileConfig {
port?: number; port?: number;
bind?: string; bind?: string;
dbPath?: string; dbPath?: string;
hideAfterMinutes?: number;
deleteAfterMinutes?: number;
sweepIntervalMinutes?: number;
} }
export interface DaemonConfig { export interface DaemonConfig {
port: number; port: number;
bind: string; bind: string;
dbPath: string; dbPath: string;
hideAfterMinutes: number;
deleteAfterMinutes: number;
sweepIntervalMinutes: number;
} }
export function defaultDbPath(): string { export function defaultDbPath(): string {
@@ -65,6 +74,12 @@ export function loadFileConfig(explicitPath?: string): FileConfig {
port: typeof parsed.port === "number" ? parsed.port : undefined, port: typeof parsed.port === "number" ? parsed.port : undefined,
bind: typeof parsed.bind === "string" ? parsed.bind : undefined, bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined, dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
hideAfterMinutes:
typeof parsed.hideAfterMinutes === "number" ? parsed.hideAfterMinutes : undefined,
deleteAfterMinutes:
typeof parsed.deleteAfterMinutes === "number" ? parsed.deleteAfterMinutes : undefined,
sweepIntervalMinutes:
typeof parsed.sweepIntervalMinutes === "number" ? parsed.sweepIntervalMinutes : undefined,
}; };
} }
} }
@@ -76,6 +91,9 @@ export interface ServeOverrides {
bind?: string; bind?: string;
dbPath?: string; dbPath?: string;
config?: string; config?: string;
hideAfterMinutes?: number;
deleteAfterMinutes?: number;
sweepIntervalMinutes?: number;
} }
export function resolveConfig(overrides: ServeOverrides): DaemonConfig { export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
@@ -83,7 +101,20 @@ export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
const port = overrides.port ?? file.port ?? DEFAULT_PORT; const port = overrides.port ?? file.port ?? DEFAULT_PORT;
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND; const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath(); const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
return { port, bind, dbPath: expandPath(dbPathRaw) }; const hideAfterMinutes =
overrides.hideAfterMinutes ?? file.hideAfterMinutes ?? DEFAULT_HIDE_AFTER_MINUTES;
const deleteAfterMinutes =
overrides.deleteAfterMinutes ?? file.deleteAfterMinutes ?? DEFAULT_DELETE_AFTER_MINUTES;
const sweepIntervalMinutes =
overrides.sweepIntervalMinutes ?? file.sweepIntervalMinutes ?? DEFAULT_SWEEP_INTERVAL_MINUTES;
return {
port,
bind,
dbPath: expandPath(dbPathRaw),
hideAfterMinutes,
deleteAfterMinutes,
sweepIntervalMinutes,
};
} }
export function baseUrl(cfg: { port: number; bind: string }): string { export function baseUrl(cfg: { port: number; bind: string }): string {

View File

@@ -90,12 +90,17 @@ export class MailboxStore {
insertMailbox: StatementSync; insertMailbox: StatementSync;
touchMailbox: StatementSync; touchMailbox: StatementSync;
listMailboxes: StatementSync; listMailboxes: StatementSync;
listMailboxesFiltered: StatementSync;
listMailboxesFilteredAnon: StatementSync;
insertMessage: StatementSync; insertMessage: StatementSync;
countPending: StatementSync; countPending: StatementSync;
oldestPending: StatementSync; oldestPending: StatementSync;
selectPending: StatementSync; selectPending: StatementSync;
markDelivered: StatementSync; markDelivered: StatementSync;
pendingByRecipient: StatementSync; pendingByRecipient: StatementSync;
findStaleCandidates: StatementSync;
deleteMessagesForNames: StatementSync;
deleteMailboxesByNames: StatementSync;
}; };
constructor(public readonly dbPath: string) { constructor(public readonly dbPath: string) {
@@ -112,6 +117,19 @@ export class MailboxStore {
), ),
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"), touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"), listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
listMailboxesFiltered: this.db.prepare(
`SELECT * FROM mailboxes
WHERE last_seen_at >= ?
OR name = ?
OR name IN (
SELECT DISTINCT from_mailbox FROM messages
WHERE to_mailbox = ? AND delivered_at IS NULL
)
ORDER BY name`,
),
listMailboxesFilteredAnon: this.db.prepare(
"SELECT * FROM mailboxes WHERE last_seen_at >= ? ORDER BY name",
),
insertMessage: this.db.prepare( insertMessage: this.db.prepare(
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)", "INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
), ),
@@ -130,6 +148,20 @@ export class MailboxStore {
pendingByRecipient: this.db.prepare( pendingByRecipient: this.db.prepare(
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox", "SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
), ),
findStaleCandidates: this.db.prepare(
`SELECT name FROM mailboxes
WHERE last_seen_at < ?
AND name NOT IN (SELECT to_mailbox FROM messages WHERE delivered_at IS NULL)
AND name NOT IN (SELECT from_mailbox FROM messages WHERE delivered_at IS NULL)`,
),
deleteMessagesForNames: this.db.prepare(
`DELETE FROM messages
WHERE to_mailbox IN (SELECT value FROM json_each(?))
OR from_mailbox IN (SELECT value FROM json_each(?))`,
),
deleteMailboxesByNames: this.db.prepare(
"DELETE FROM mailboxes WHERE name IN (SELECT value FROM json_each(?))",
),
}; };
} }
@@ -204,8 +236,23 @@ export class MailboxStore {
}); });
} }
listMailboxes(forName?: string): MailboxInfo[] { listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[]; const hideAfterMinutes = options?.hideAfterMinutes;
let rows: MailboxRow[];
if (hideAfterMinutes != null && hideAfterMinutes > 0) {
const cutoff = new Date(Date.now() - hideAfterMinutes * 60_000).toISOString();
if (forName) {
rows = this.stmts.listMailboxesFiltered.all(
cutoff,
forName,
forName,
) as unknown as MailboxRow[];
} else {
rows = this.stmts.listMailboxesFilteredAnon.all(cutoff) as unknown as MailboxRow[];
}
} else {
rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
}
const pendingMap = new Map<string, number>(); const pendingMap = new Map<string, number>();
if (forName) { if (forName) {
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[]; const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
@@ -217,6 +264,22 @@ export class MailboxStore {
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0, pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
})); }));
} }
pruneStale(deleteAfterMinutes: number): { deletedMailboxes: number; deletedMessages: number } {
if (deleteAfterMinutes <= 0) return { deletedMailboxes: 0, deletedMessages: 0 };
const cutoff = new Date(Date.now() - deleteAfterMinutes * 60_000).toISOString();
return runInTransaction(this.db, () => {
const candidates = this.stmts.findStaleCandidates.all(cutoff) as { name: string }[];
if (candidates.length === 0) return { deletedMailboxes: 0, deletedMessages: 0 };
const namesJson = JSON.stringify(candidates.map((c) => c.name));
const msgResult = this.stmts.deleteMessagesForNames.run(namesJson, namesJson);
const mbxResult = this.stmts.deleteMailboxesByNames.run(namesJson);
return {
deletedMailboxes: Number(mbxResult.changes ?? 0),
deletedMessages: Number(msgResult.changes ?? 0),
};
});
}
} }
export function rowToMessage(r: MessageRow): { export function rowToMessage(r: MessageRow): {

View File

@@ -27,7 +27,7 @@ export function resolveIdentity(
); );
} }
function buildMcpServer(store: MailboxStore): McpServer { function buildMcpServer(store: MailboxStore, hideAfterMinutes: number): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" }); const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
server.registerTool( server.registerTool(
@@ -129,7 +129,7 @@ function buildMcpServer(store: MailboxStore): McpServer {
}, },
async ({ name }, extra) => { async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name"); const me = resolveIdentity(name, extra, "name");
const list = store.listMailboxes(me).map((m) => ({ const list = store.listMailboxes(me, { hideAfterMinutes }).map((m) => ({
name: m.name, name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(), lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou, pendingForYou: m.pendingForYou,
@@ -180,8 +180,12 @@ function buildMcpServer(store: MailboxStore): McpServer {
return server; return server;
} }
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> { export async function registerMcp(
const mcpServer = buildMcpServer(store); app: FastifyInstance,
store: MailboxStore,
hideAfterMinutes: number,
): Promise<void> {
const mcpServer = buildMcpServer(store, hideAfterMinutes);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport); await mcpServer.connect(transport);

View File

@@ -93,11 +93,13 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
app.get("/v1/list", async (req) => { app.get("/v1/list", async (req) => {
const name = req.mailboxName; const name = req.mailboxName;
return store.listMailboxes(name).map((m) => ({ return store
name: m.name, .listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
lastSeenAt: m.lastSeenAt.toISOString(), .map((m) => ({
pendingForYou: m.pendingForYou, name: m.name,
})); lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
}));
}); });
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => { app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
@@ -119,14 +121,45 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
} }
}); });
await registerMcp(app, store); await registerMcp(app, store, cfg.hideAfterMinutes);
return app; return app;
} }
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> { function startSweep(
store: MailboxStore,
cfg: DaemonConfig,
log: FastifyInstance["log"],
): NodeJS.Timeout | null {
if (cfg.sweepIntervalMinutes <= 0 || cfg.deleteAfterMinutes <= 0) return null;
const runOnce = (): void => {
try {
const r = store.pruneStale(cfg.deleteAfterMinutes);
if (r.deletedMailboxes > 0 || r.deletedMessages > 0) {
log.info(
r,
`Pruned ${r.deletedMailboxes} stale mailbox(es) and ${r.deletedMessages} delivered message(s)`,
);
}
} catch (err) {
log.error({ err }, "Stale-mailbox sweep failed");
}
};
runOnce();
const timer = setInterval(runOnce, cfg.sweepIntervalMinutes * 60_000);
timer.unref?.();
return timer;
}
export async function startServer(
cfg: DaemonConfig,
): Promise<{ app: FastifyInstance; store: MailboxStore; sweepTimer: NodeJS.Timeout | null }> {
const store = new MailboxStore(cfg.dbPath); const store = new MailboxStore(cfg.dbPath);
const app = await buildServer(cfg, store); const app = await buildServer(cfg, store);
await app.listen({ host: cfg.bind, port: cfg.port }); await app.listen({ host: cfg.bind, port: cfg.port });
return { app, store }; const sweepTimer = startSweep(store, cfg, app.log);
app.addHook("onClose", async () => {
if (sweepTimer) clearInterval(sweepTimer);
});
return { app, store, sweepTimer };
} }

View File

@@ -2,8 +2,16 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MailboxStore, RenameError } from "../src/db.js"; import { MailboxStore, RenameError } from "../src/db.js";
function backdate(dbPath: string, name: string, minutesAgo: number): void {
const db = new DatabaseSync(dbPath);
const iso = new Date(Date.now() - minutesAgo * 60_000).toISOString();
db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(iso, name);
db.close();
}
let dir: string; let dir: string;
let dbPath: string; let dbPath: string;
@@ -178,4 +186,127 @@ describe("listMailboxes", () => {
store.close(); store.close();
} }
}); });
it("hides mailboxes older than hideAfterMinutes when filter is active", () => {
const store = new MailboxStore(dbPath);
try {
store.upsertMailbox("recent");
store.upsertMailbox("stale");
store.close();
backdate(dbPath, "stale", 90);
const store2 = new MailboxStore(dbPath);
try {
const filtered = store2.listMailboxes(undefined, { hideAfterMinutes: 60 });
expect(filtered.map((m) => m.name)).toEqual(["recent"]);
const unfiltered = store2.listMailboxes();
expect(unfiltered.map((m) => m.name).sort()).toEqual(["recent", "stale"]);
} finally {
store2.close();
}
} catch (e) {
store.close();
throw e;
}
});
it("always includes the caller and senders with pending messages, even if stale", () => {
const store = new MailboxStore(dbPath);
try {
store.send("stale-sender", "me", "you have mail");
store.upsertMailbox("recent-other");
store.upsertMailbox("stale-other");
store.close();
backdate(dbPath, "stale-sender", 120);
backdate(dbPath, "stale-other", 120);
backdate(dbPath, "me", 120);
const store2 = new MailboxStore(dbPath);
try {
const filtered = store2.listMailboxes("me", { hideAfterMinutes: 60 });
const names = filtered.map((m) => m.name).sort();
expect(names).toContain("me");
expect(names).toContain("stale-sender");
expect(names).toContain("recent-other");
expect(names).not.toContain("stale-other");
} finally {
store2.close();
}
} catch (e) {
store.close();
throw e;
}
});
});
describe("pruneStale", () => {
it("deletes idle mailboxes with no pending messages and wipes their delivered history", () => {
const store = new MailboxStore(dbPath);
try {
store.send("alice", "bob", "old");
store.checkInbox("bob");
store.upsertMailbox("fresh");
store.close();
backdate(dbPath, "alice", 60 * 24 * 8);
backdate(dbPath, "bob", 60 * 24 * 8);
const store2 = new MailboxStore(dbPath);
try {
const r = store2.pruneStale(60 * 24 * 7);
expect(r.deletedMailboxes).toBe(2);
expect(r.deletedMessages).toBe(1);
const remaining = store2.listMailboxes().map((m) => m.name);
expect(remaining).toEqual(["fresh"]);
} finally {
store2.close();
}
} catch (e) {
store.close();
throw e;
}
});
it("never deletes a mailbox that still has pending messages, even if idle", () => {
const store = new MailboxStore(dbPath);
try {
store.send("alice", "bob", "still pending");
store.close();
backdate(dbPath, "alice", 60 * 24 * 30);
backdate(dbPath, "bob", 60 * 24 * 30);
const store2 = new MailboxStore(dbPath);
try {
const r = store2.pruneStale(60 * 24 * 7);
expect(r.deletedMailboxes).toBe(0);
expect(r.deletedMessages).toBe(0);
expect(store2.peek("bob").pending).toBe(1);
} finally {
store2.close();
}
} catch (e) {
store.close();
throw e;
}
});
it("returns zero when deleteAfterMinutes is 0 (disabled)", () => {
const store = new MailboxStore(dbPath);
try {
store.upsertMailbox("x");
store.close();
backdate(dbPath, "x", 60 * 24 * 365);
const store2 = new MailboxStore(dbPath);
try {
const r = store2.pruneStale(0);
expect(r).toEqual({ deletedMailboxes: 0, deletedMessages: 0 });
expect(store2.listMailboxes().map((m) => m.name)).toEqual(["x"]);
} finally {
store2.close();
}
} catch (e) {
store.close();
throw e;
}
});
}); });

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MailboxStore } from "../src/db.js"; import { MailboxStore } from "../src/db.js";
import { buildServer } from "../src/server.js"; import { buildServer } from "../src/server.js";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -16,7 +17,17 @@ beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-")); dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
dbPath = join(dir, "test.db"); dbPath = join(dir, "test.db");
store = new MailboxStore(dbPath); store = new MailboxStore(dbPath);
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store); app = await buildServer(
{
port: 0,
bind: "127.0.0.1",
dbPath,
hideAfterMinutes: 0,
deleteAfterMinutes: 0,
sweepIntervalMinutes: 0,
},
store,
);
await app.listen({ host: "127.0.0.1", port: 0 }); await app.listen({ host: "127.0.0.1", port: 0 });
const addr = app.server.address(); const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address"); if (!addr || typeof addr === "string") throw new Error("no address");
@@ -151,6 +162,42 @@ describe("REST surface", () => {
expect(missingTo.status).toBe(400); expect(missingTo.status).toBe(400);
}); });
it("/v1/list filters out mailboxes idle beyond hideAfterMinutes", async () => {
await app.close();
store.close();
store = new MailboxStore(dbPath);
store.upsertMailbox("recent");
store.upsertMailbox("stale");
store.close();
const handle = new DatabaseSync(dbPath);
const past = new Date(Date.now() - 120 * 60_000).toISOString();
handle.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?").run(past, "stale");
handle.close();
store = new MailboxStore(dbPath);
app = await buildServer(
{
port: 0,
bind: "127.0.0.1",
dbPath,
hideAfterMinutes: 60,
deleteAfterMinutes: 0,
sweepIntervalMinutes: 0,
},
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}`;
const r = await call("GET", "/v1/list");
expect(r.status).toBe(200);
const names = (r.body as Array<{ name: string }>).map((m) => m.name);
expect(names).toContain("recent");
expect(names).not.toContain("stale");
});
it("/v1/list and /v1/peek are anonymous", async () => { it("/v1/list and /v1/peek are anonymous", async () => {
await call("POST", "/v1/send", { await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" }, headers: { "X-Mailbox": "alice" },

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-mailbox", "name": "claude-mailbox",
"version": "1.4.0", "version": "1.5.0",
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.", "description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
"author": { "author": {
"name": "Mika Kuns" "name": "Mika Kuns"