feat(quick-260914-m97): Fehlermeldungen per E-Mail — Empfaenger in SmtpConfig (Migration), MailService-Anhaenge, Modul bug-reports mit Drossel, PNG-Pruefung und Mandant aus der Sitzung

- SmtpConfig.bugReportRecipient (nullable, additive Migration 20260914170000), DTO @IsOptional @IsEmail, SAFE_SELECT, getBugReportRecipient gebunden
- MailService: Versandkern deliver (wirft, Anhaenge), sendViaTenantTransport bleibt verschluckender Mantel (T-02-12), sendBugReport laesst Fehler durch
- POST /bug-reports: Multipart 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min -> 429, PNG-Signatur -> 400, kein Empfaenger -> 409, Versandfehler -> 502, eine Protokollzeile
- Falsifizierungen (a)-(d) als Specs; @Expose() im DTO, damit errors auch bei fehlendem Feld zu [] wird
- Doku-Zeile fuer rls-access-inventory, TESSERA_BUGREPORT_TO in docker-compose.prod.yml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:45:09 +02:00
parent 17a7e5ef9b
commit 54121c1721
16 changed files with 919 additions and 12 deletions
@@ -48,4 +48,15 @@ export class SmtpConfigDto {
@IsOptional()
@IsEmail()
testTo?: string;
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97). Optional:
* fehlt das Feld im PUT, bleibt der gespeicherte Wert; `null` loescht ihn.
* Absicht (T-M97-05): nur ein Administrator dieses Mandanten kann das
* Ziel aller Fehlermeldungen seines Mandanten setzen — der Weg fuehrt
* ausschliesslich ueber `PUT /settings/smtp` mit `@Roles(ADMIN, SUPER_ADMIN)`.
*/
@IsOptional()
@IsEmail()
bugReportRecipient?: string | null;
}
@@ -40,6 +40,7 @@ interface FakeSmtpRow {
username: string | null;
encryptedPassword: string | null;
fromAddress: string;
bugReportRecipient?: string | null;
createdAt?: Date;
updatedAt?: Date;
}
@@ -483,4 +484,71 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('bugReportRecipient — Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)', () => {
const rowWithRecipient: FakeSmtpRow = {
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
};
it('Test A: getBugReportRecipient liefert den gespeicherten Wert ueber GENAU EINEN gebundenen findUnique; fremder Mandant oder null-Feld -> null', async () => {
const prisma = makeFakePrisma([
rowWithRecipient,
{ ...rowWithRecipient, id: 'smtp-c', tenantId: 't3', bugReportRecipient: null },
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
const found = await service.getBugReportRecipient('t1');
expect(found).toBe('fehler@a.example.invalid');
expectBoundCall(prisma, 't1', 'findUnique');
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
expect(await service.getBugReportRecipient('t2')).toBeNull();
expect(await service.getBugReportRecipient('t3')).toBeNull();
});
it('Test B: saveSmtpConfig mit bugReportRecipient speichert den Wert; Rueckgabe traegt bugReportRecipient und KEIN encryptedPassword', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.saveSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
} as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).encryptedPassword).toBeUndefined();
});
it('Test C: DTO OHNE das Feld bewahrt den gespeicherten Wert, DTO mit null loescht ihn', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const base = {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
};
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: 'fehler@a.example.invalid' } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: null } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBeNull();
});
});
});
+23
View File
@@ -19,6 +19,7 @@ const SMTP_SAFE_SELECT = {
username: true,
// encryptedPassword: NEVER included — T-07-07
fromAddress: true,
bugReportRecipient: true, // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)
createdAt: true,
updatedAt: true,
} as const;
@@ -77,6 +78,10 @@ export class SettingsService {
username: dto.username ?? null,
fromAddress: dto.fromAddress,
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
// quick-260914-m97: fehlendes Feld = bewahren, null/leer = loeschen
...(dto.bugReportRecipient !== undefined
? { bugReportRecipient: dto.bugReportRecipient || null }
: {}),
};
const result = await tenantPrisma.smtpConfig.upsert({
@@ -89,6 +94,24 @@ export class SettingsService {
return result;
}
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97): der Wert aus
* `SmtpConfig.bugReportRecipient` des Mandanten oder `null`, wenn keine
* Zeile existiert oder das Feld leer ist. Verwender: `BugReportsService`
* (der dort den Umgebungs-Rueckfall `TESSERA_BUGREPORT_TO` anhaengt).
*
* Mandantengebunden: EIN Klient `tenantPrisma`, `findUnique` mit
* schmalem `select` — das verschluesselte Kennwort wird hier nie geladen.
*/
async getBugReportRecipient(tenantId: string): Promise<string | null> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const row = await tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
select: { bugReportRecipient: true },
});
return row?.bugReportRecipient ?? null;
}
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von