From 9ddc0a3d033cc9dc323d5ecfe24a77d6557d2a36 Mon Sep 17 00:00:00 2001 From: Anvil Date: Tue, 28 Apr 2026 02:55:33 +0000 Subject: [PATCH] fix(mail): auto-purge cur/ on ack to prevent quota accumulation (ops-xi76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ackMessage now calls unlinkSync(path) after writing the ack, removing the file from cur/. This prevents acked messages from accumulating against the MAX_INBOX_MESSAGES=100 quota. countInboxMessages counts new/ + cur/. Since ack no longer leaves files in cur/, acked messages no longer count against quota. gcMessages still handles DLQ and hard-TTL recovery; acked messages in cur/ are already gone. Tests: - ackMessage removes the file from cur/ - countInboxMessages drops after ack (5→3 acked→2 remaining) --- packages/cli/src/utils/mail.ts | 4 +++- packages/cli/test/mail.test.ts | 42 +++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/mail.ts b/packages/cli/src/utils/mail.ts index 6ca720e..d9fd2c3 100644 --- a/packages/cli/src/utils/mail.ts +++ b/packages/cli/src/utils/mail.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -256,6 +256,8 @@ export function ackMessage(agent: string, id: string): MailMessage | null { delete msg.checkedOutBy; delete msg.retryAfter; writeMessageFile(path, msg); + // Remove the file from cur/ now that it's acked — audit trail is in log/ + try { unlinkSync(path); } catch { /* best effort — don't fail ack if cleanup fails */ } return msg; } diff --git a/packages/cli/test/mail.test.ts b/packages/cli/test/mail.test.ts index 2f3088f..da8522d 100644 --- a/packages/cli/test/mail.test.ts +++ b/packages/cli/test/mail.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, readdirSync, mkdirSync, writeFileSync } from "node import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; -import { checkMessages, getInbox, inboxExists, listMessages, sendMessage } from "../src/utils/mail.js"; +import { checkMessages, getInbox, inboxExists, listMessages, sendMessage, ackMessage, countInboxMessages } from "../src/utils/mail.js"; const TPS_BIN = resolve(import.meta.dir, "../bin/tps.ts"); @@ -87,6 +87,46 @@ describe("mail utils", () => { expect(inboxExists("../etc/passwd")).toBe(false); expect(inboxExists("")).toBe(false); }); + + test("ackMessage removes the file from cur/", () => { + const m = sendMessage("kern", "ack-test", "anvil"); + expect(m.to).toBe("kern"); + const inbox = getInbox("kern"); + + // Move from new -> cur via check + checkMessages("kern"); + expect(readdirSync(inbox.fresh).filter((f) => f.endsWith(".json")).length).toBe(0); + + const curFilesBefore = readdirSync(inbox.cur).filter((f) => f.endsWith(".json")); + expect(curFilesBefore.length).toBe(1); + + // Ack removes it + const acked = ackMessage("kern", m.id); + expect(acked).not.toBeNull(); + + const curFilesAfter = readdirSync(inbox.cur).filter((f) => f.endsWith(".json")); + expect(curFilesAfter.length).toBe(0); + }); + + test("countInboxMessages drops after ack", () => { + // Send 5 messages + for (let i = 0; i < 5; i++) { + sendMessage("kern", `msg-${i}`, "anvil"); + } + expect(countInboxMessages("kern")).toBe(5); + + // Check all (moves new -> cur) + const msgs = checkMessages("kern"); + expect(msgs.length).toBe(5); + expect(countInboxMessages("kern")).toBe(5); + + // Ack 3 + ackMessage("kern", msgs[0]!.id); + ackMessage("kern", msgs[1]!.id); + ackMessage("kern", msgs[2]!.id); + + expect(countInboxMessages("kern")).toBe(2); + }); }); describe("mail command", () => {