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:
@@ -78,7 +78,30 @@ program
|
||||
.option("--bind <address>", "Bind address")
|
||||
.option("--db-path <path>", "SQLite database path")
|
||||
.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);
|
||||
try {
|
||||
const { startServer } = await import("./server.js");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, number>();
|
||||
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): {
|
||||
|
||||
@@ -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<void> {
|
||||
const mcpServer = buildMcpServer(store);
|
||||
export async function registerMcp(
|
||||
app: FastifyInstance,
|
||||
store: MailboxStore,
|
||||
hideAfterMinutes: number,
|
||||
): Promise<void> {
|
||||
const mcpServer = buildMcpServer(store, hideAfterMinutes);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ 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) => ({
|
||||
return store
|
||||
.listMailboxes(name, { hideAfterMinutes: cfg.hideAfterMinutes })
|
||||
.map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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