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