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
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
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
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 loop’s 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);
|
||
});
|
||
});
|