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
+52 -1
View File
@@ -18,7 +18,14 @@ import { MailService } from './mail.service';
* protokolliert, `close()` trotzdem gerufen.
*
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`.
* — kein echter Transport, der lokale `mailhog` aus docker-compose.dev.yml
* ist nur fuer den Browser-Check gedacht.
*
* Erweitert in quick-260914-m97 (Fehler-melden-Knopf): Tests 5 und 6 pinnen
* `sendBugReport` — Anhaenge werden 1:1 an `sendMail` durchgereicht, und
* Fehler gehen bewusst NACH AUSSEN (der Anwender soll wissen, ob sein
* Bericht ankam), waehrend `sendPasswordResetEmail` weiterhin verschluckt
* (T-02-12 unveraendert, Gegenprobe im selben Test).
*/
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
@@ -196,4 +203,48 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 5 (260914-m97): sendBugReport reicht to/subject/text und den PNG-Anhang unveraendert an sendMail durch, from = fromAddress des Mandanten, close() gerufen', async () => {
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const png = Buffer.from([1, 2, 3]);
await service.sendBugReport('t1', 'fehler@a.example.invalid', {
subject: 'S',
text: 'T',
attachments: [{ filename: 'x.png', content: png, contentType: 'image/png' }],
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('fehler@a.example.invalid');
expect(sent.subject).toBe('S');
expect(sent.text).toBe('T');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('x.png');
expect(sent.attachments[0].contentType).toBe('image/png');
expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true);
expect((sent.attachments[0].content as Buffer).equals(png)).toBe(true);
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 6 (260914-m97): sendBugReport laesst Transportfehler DURCH (rejects), close() trotzdem; Gegenprobe: sendPasswordResetEmail verschluckt denselben Fehler weiterhin (T-02-12)', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [] }),
).rejects.toThrow('ECONNREFUSED');
expect(mockClose).toHaveBeenCalledTimes(1);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(mockClose).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalled();
});
});
+63 -11
View File
@@ -37,11 +37,36 @@ import { SettingsService } from '../settings/settings.service';
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* `resolveTransport`/`deliver` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
*/
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
export interface OutgoingAttachment {
filename: string;
content: Buffer;
contentType: string;
}
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
export interface OutgoingMail {
to: string;
subject: string;
text: string;
html?: string;
attachments?: OutgoingAttachment[];
}
/** Was `BugReportsService` liefert — Empfaenger und Mandant kommen getrennt. */
export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments'>;
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
@@ -128,15 +153,12 @@ export class MailService {
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
* Der eine Versandkern: Transport je Versand aus `resolveTransport`,
* `sendMail` mit optionalem HTML und Anhaengen, `close()` im `finally`
* (WR-01 — keine offenen Verbindungen). WIRFT bei Transportfehlern —
* ob der Fehler nach aussen geht, entscheidet der Aufrufer.
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
private async deliver(tenantId: string, mail: OutgoingMail, kind: string): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
@@ -146,19 +168,49 @@ export class MailService {
to: mail.to,
subject: mail.subject,
text: mail.text,
...(mail.html !== undefined ? { html: mail.html } : {}),
...(mail.attachments !== undefined ? { attachments: mail.attachments } : {}),
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} finally {
transport?.close();
}
}
/**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
* bleibt fuer genau diese beiden Wege bestehen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
try {
await this.deliver(tenantId, mail, kind);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Fehlermeldung eines Anwenders (quick-260914-m97) mit PNG-Anhang an das
* eingestellte Postfach des Mandanten. Fehler gehen BEWUSST nach aussen —
* anders als bei T-02-12: hier gibt es nichts zu verbergen (kein
* Anmeldeweg, kein Enumerationsrisiko), und der Anwender soll wissen, ob
* sein Bericht angekommen ist. `BugReportsService` uebersetzt den Fehler
* in eine 502-Antwort.
*/
async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise<void> {
await this.deliver(tenantId, { to, ...report }, 'Bug report');
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds