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:
@@ -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): {
|
||||
|
||||
Reference in New Issue
Block a user