fix(email): wire the settings status card, and cap-aware truncation

Codex review round 4 on #1273.

Settings → Status rendered a green check for the email processor
unconditionally, against an API field that was itself the literal 'active'.
Both ends were lying and only one of them got fixed: adminSystem started
reporting the real state in an earlier commit, but StatusTab never read it, so
the second place an admin looks to find out why mail is not arriving still said
everything was fine. It now shows stopped and degraded, with the reason.

The truncation flag missed the case it most needed to cover. The loop broke on
the 200-row report cap before the flag could be set, so 201+ overdue rows came
back as exactly 200 with scanTruncated false -- a partial report presented as
complete. It is now set whenever rows were left unexamined.

The grace-window comment claimed the processor clears ~6000 rows inside the
window. It clears on the order of 100: ten rows a pass, one pass a minute. The
comment now says so, and says why the processor's own state is reported above
the list rather than inferred from it -- "running, last pass sent 10" next to a
backlog reads very differently from "not running" next to the same backlog.

One round-4 finding is NOT fixed, deliberately, and is written up at the retry
route. Clearing scheduled_at leaves created_at at the original enqueue time, so
a retried old row appears in the waiting list immediately, looking overdue,
until the processor sends it. Restarting that clock needs a timestamp written
there and no shape works: a Date matches how queueEmail writes the column and
how processEmailQueue compares it, but jest's sandbox Dates store as
"[object Object]" (CLAUDE.md) so it cannot be tested; an ISO string tests fine
but stores as TEXT, which SQLite then orders above the numeric bound in the
processor's own pickup query, leaving the row unsendable. A requeued_at column
would settle it. Cosmetic either way, and not worth risking a stuck row.

1 more test, failing before this commit.
This commit is contained in:
Paul Nothaft
2026-09-02 15:32:35 +02:00
parent 4deac229ac
commit 2d403f7fb2
6 changed files with 83 additions and 10 deletions
@@ -300,6 +300,29 @@ describe('GET /admin/system-health/failures — waiting emails (#1262)', () => {
expect(body.counts.pendingScanned).toBe(0);
});
it('calls a report capped at the row limit truncated, not complete', async () => {
// Codex review round 4. The loop broke on the report cap before the
// truncation flag could be set, so 201+ overdue rows came back as exactly
// 200 with scanTruncated false — a partial report presented as the whole.
const rows = [];
for (let i = 0; i < 260; i += 1) {
rows.push({
recipient_email: `overdue${i}@example.com`,
email_type: 'gallery_created',
email_data: '{}',
status: 'pending',
retry_count: 0,
created_at: ago(90 * MINUTE),
scheduled_at: ago(90 * MINUTE),
});
}
await db.batchInsert('email_queue', rows, 100);
const body = await failures();
expect(body.counts.waitingEmails).toBe(200);
expect(body.scanTruncated).toBe(true);
});
it('reports what the queue processor last did', async () => {
const body = await failures();
// Never started in this process — which is the condition that makes a