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.
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
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 {
|
|
return join(homedir(), ".claude-mailbox", "mailbox.db");
|
|
}
|
|
|
|
export function userConfigPath(): string {
|
|
return join(homedir(), ".claude-mailbox", "mailbox.json");
|
|
}
|
|
|
|
export function machineConfigPath(): string | null {
|
|
if (process.platform === "win32") {
|
|
const programData = process.env["ProgramData"] ?? "C:\\ProgramData";
|
|
return join(programData, "ClaudeMailbox", "mailbox.json");
|
|
}
|
|
if (process.platform === "darwin") {
|
|
return "/Library/Application Support/ClaudeMailbox/mailbox.json";
|
|
}
|
|
return "/etc/claude-mailbox/mailbox.json";
|
|
}
|
|
|
|
function expandPath(p: string): string {
|
|
let out = p;
|
|
if (out.startsWith("~")) out = join(homedir(), out.slice(1));
|
|
out = out.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
|
|
out = out.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => process.env[name] ?? "");
|
|
return resolve(out);
|
|
}
|
|
|
|
export function loadFileConfig(explicitPath?: string): FileConfig {
|
|
const candidates: string[] = [];
|
|
if (explicitPath) {
|
|
if (!existsSync(explicitPath)) {
|
|
throw new Error(`Config file not found: ${explicitPath}`);
|
|
}
|
|
candidates.push(explicitPath);
|
|
} else {
|
|
candidates.push(userConfigPath());
|
|
const machine = machineConfigPath();
|
|
if (machine) candidates.push(machine);
|
|
}
|
|
|
|
for (const path of candidates) {
|
|
if (existsSync(path)) {
|
|
const raw = readFileSync(path, "utf8");
|
|
const parsed = JSON.parse(raw) as FileConfig;
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
export interface ServeOverrides {
|
|
port?: number;
|
|
bind?: string;
|
|
dbPath?: string;
|
|
config?: string;
|
|
hideAfterMinutes?: number;
|
|
deleteAfterMinutes?: number;
|
|
sweepIntervalMinutes?: number;
|
|
}
|
|
|
|
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
|
const file = loadFileConfig(overrides.config);
|
|
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
|
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
|
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
|
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 {
|
|
return `http://${cfg.bind}:${cfg.port}`;
|
|
}
|