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:
Mika Kuns
2026-05-20 13:54:03 +02:00
parent 06a2ea6b7b
commit 0c06e2cf4b
7 changed files with 349 additions and 17 deletions

View File

@@ -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;
}
});
});