dorfteich/apps/api/src/mail/mail-outbox-retention.e2e.db.test.ts
Claude Fable 5 69d9072d2c
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m51s
CI / Build container images (pull_request) Successful in 3m4s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#234: retention for mail_outbox
Sent mails were kept forever, and digest bodies name page titles and
actors — an unbounded copy of content-adjacent data. A new daily
mail-outbox-retention job deletes SENT rows (by sentAt) and permanently
FAILED rows (by nextAttemptAt, the last attempt's stamp) once they pass
mail.outboxRetentionDays (instance setting, default 30). PENDING rows —
including failed-but-retryable ones — stay the retry loop's alone.

Decision recorded (security.md §Privacy, residual-risk note for #231):
digest mails keep carrying page titles for now — there is no per-page
classification marking yet to key a suppression on (ADR 0022 / M32
revisits), and a VS-NfD reference configuration can leave SMTP
unconfigured entirely.

Job-count fence in system.spec: 8 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 22:17:28 +02:00

105 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { MailRetentionService } from './mail-retention.service';
const DAY = 24 * 60 * 60 * 1000;
/**
* Mail outbox retention (issue #234): SENT and permanently FAILED rows past
* `mail.outboxRetentionDays` are deleted; PENDING rows — including a
* failed-but-retryable one — belong to the retry loop and stay untouched.
*/
describe.skipIf(!hasTestDb)('mail outbox retention (e2e, issue #234)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const address = (name: string) => `${name}-${suffix}@example.org`;
beforeAll(async () => {
prisma = createTestPrisma();
// A short period so ages are unambiguous; written straight to the row
// BEFORE the app boots (the settings cache is in-process and fills on
// first read). The key is cleaned afterAll.
await prisma.instanceSetting.upsert({
where: { key: 'mail.outboxRetentionDays' },
create: { key: 'mail.outboxRetentionDays', value: 10 },
update: { value: 10 },
});
app = await createTestApp();
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'mail.outboxRetentionDays' } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('deletes terminal rows past the period, keeps the retry loops rows', async () => {
await prisma.mailOutbox.createMany({
data: [
{
toAddress: address('sent-old'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'SENT',
sentAt: new Date(Date.now() - 15 * DAY),
},
{
toAddress: address('sent-fresh'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'SENT',
sentAt: new Date(Date.now() - 5 * DAY),
},
{
// Gave up after MAX_ATTEMPTS; nextAttemptAt records the last try.
toAddress: address('failed-old'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'FAILED',
attempts: 5,
nextAttemptAt: new Date(Date.now() - 15 * DAY),
lastError: 'smtp gone',
},
{
// Failed once but retryable: still PENDING, still the worker's.
toAddress: address('pending-retry'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'PENDING',
attempts: 2,
nextAttemptAt: new Date(Date.now() - 15 * DAY),
lastError: 'transient',
createdAt: new Date(Date.now() - 15 * DAY),
},
],
});
const pruned = await app.get(MailRetentionService).pruneExpired();
expect(pruned).toBeGreaterThanOrEqual(2);
const remaining = await prisma.mailOutbox.findMany({
where: { toAddress: { contains: suffix } },
});
expect(remaining.map((row) => row.toAddress).sort()).toEqual([
address('pending-retry'),
address('sent-fresh'),
]);
});
it('is a no-op when nothing is due', async () => {
expect(await app.get(MailRetentionService).pruneExpired()).toBe(0);
expect(await prisma.mailOutbox.count({ where: { toAddress: { contains: suffix } } })).toBe(2);
});
});