diff --git a/node/src/cli.ts b/node/src/cli.ts
index bf5aa5a..13698f8 100644
--- a/node/src/cli.ts
+++ b/node/src/cli.ts
@@ -78,7 +78,30 @@ program
.option("--bind
", "Bind address")
.option("--db-path ", "SQLite database path")
.option("--config ", "Path to mailbox.json")
- .action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
+ .option(
+ "--hide-after-minutes ",
+ "Hide mailboxes idle longer than N minutes from list responses (0 = disabled)",
+ (v) => parseInt(v, 10),
+ )
+ .option(
+ "--delete-after-minutes ",
+ "Hard-delete mailboxes idle longer than N minutes (0 = disabled)",
+ (v) => parseInt(v, 10),
+ )
+ .option(
+ "--sweep-interval-minutes ",
+ "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);
try {
const { startServer } = await import("./server.js");
diff --git a/node/src/config.ts b/node/src/config.ts
index b116989..a1df4e3 100644
--- a/node/src/config.ts
+++ b/node/src/config.ts
@@ -4,17 +4,26 @@ import { join, resolve } from "node:path";
export const DEFAULT_PORT = 37849;
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 {
port?: number;
bind?: string;
dbPath?: string;
+ hideAfterMinutes?: number;
+ deleteAfterMinutes?: number;
+ sweepIntervalMinutes?: number;
}
export interface DaemonConfig {
port: number;
bind: string;
dbPath: string;
+ hideAfterMinutes: number;
+ deleteAfterMinutes: number;
+ sweepIntervalMinutes: number;
}
export function defaultDbPath(): string {
@@ -65,6 +74,12 @@ export function loadFileConfig(explicitPath?: string): FileConfig {
port: typeof parsed.port === "number" ? parsed.port : undefined,
bind: typeof parsed.bind === "string" ? parsed.bind : 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;
dbPath?: string;
config?: string;
+ hideAfterMinutes?: number;
+ deleteAfterMinutes?: number;
+ sweepIntervalMinutes?: number;
}
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 bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
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 {
diff --git a/node/src/db.ts b/node/src/db.ts
index 942ced0..b37c331 100644
--- a/node/src/db.ts
+++ b/node/src/db.ts
@@ -90,12 +90,17 @@ export class MailboxStore {
insertMailbox: StatementSync;
touchMailbox: StatementSync;
listMailboxes: StatementSync;
+ listMailboxesFiltered: StatementSync;
+ listMailboxesFilteredAnon: StatementSync;
insertMessage: StatementSync;
countPending: StatementSync;
oldestPending: StatementSync;
selectPending: StatementSync;
markDelivered: StatementSync;
pendingByRecipient: StatementSync;
+ findStaleCandidates: StatementSync;
+ deleteMessagesForNames: StatementSync;
+ deleteMailboxesByNames: StatementSync;
};
constructor(public readonly dbPath: string) {
@@ -112,6 +117,19 @@ export class MailboxStore {
),
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE 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(
"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(
"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[] {
- const rows = this.stmts.listMailboxes.all() as unknown as MailboxRow[];
+ listMailboxes(forName?: string, options?: { hideAfterMinutes?: number }): MailboxInfo[] {
+ 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();
if (forName) {
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,
}));
}
+
+ 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): {
diff --git a/node/src/mcp.ts b/node/src/mcp.ts
index 775d6c1..5360a59 100644
--- a/node/src/mcp.ts
+++ b/node/src/mcp.ts
@@ -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" });
server.registerTool(
@@ -129,7 +129,7 @@ function buildMcpServer(store: MailboxStore): McpServer {
},
async ({ name }, extra) => {
const me = resolveIdentity(name, extra, "name");
- const list = store.listMailboxes(me).map((m) => ({
+ const list = store.listMailboxes(me, { hideAfterMinutes }).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
@@ -180,8 +180,12 @@ function buildMcpServer(store: MailboxStore): McpServer {
return server;
}
-export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise {
- const mcpServer = buildMcpServer(store);
+export async function registerMcp(
+ app: FastifyInstance,
+ store: MailboxStore,
+ hideAfterMinutes: number,
+): Promise {
+ const mcpServer = buildMcpServer(store, hideAfterMinutes);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport);
diff --git a/node/src/server.ts b/node/src/server.ts
index 810cc4b..15cc318 100644
--- a/node/src/server.ts
+++ b/node/src/server.ts
@@ -93,11 +93,13 @@ export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promi
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,
- }));
+ return store
+ .listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
+ .map((m) => ({
+ name: m.name,
+ lastSeenAt: m.lastSeenAt.toISOString(),
+ pendingForYou: m.pendingForYou,
+ }));
});
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;
}
-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 app = await buildServer(cfg, store);
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 };
}
diff --git a/node/tests/db.test.ts b/node/tests/db.test.ts
index 8f5f916..1aa9ff1 100644
--- a/node/tests/db.test.ts
+++ b/node/tests/db.test.ts
@@ -2,8 +2,16 @@ 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 { DatabaseSync } from "node:sqlite";
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 dbPath: string;
@@ -178,4 +186,127 @@ describe("listMailboxes", () => {
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;
+ }
+ });
});
diff --git a/node/tests/server.test.ts b/node/tests/server.test.ts
index bdde2bd..e8bf915 100644
--- a/node/tests/server.test.ts
+++ b/node/tests/server.test.ts
@@ -2,6 +2,7 @@ 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 { DatabaseSync } from "node:sqlite";
import { MailboxStore } from "../src/db.js";
import { buildServer } from "../src/server.js";
import type { FastifyInstance } from "fastify";
@@ -16,7 +17,17 @@ 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);
+ 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 });
const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address");
@@ -151,6 +162,42 @@ describe("REST surface", () => {
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 () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },