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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user