diff --git a/.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-PLAN.md b/.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-PLAN.md
new file mode 100644
index 0000000..d8fa295
--- /dev/null
+++ b/.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-PLAN.md
@@ -0,0 +1,373 @@
+---
+phase: quick-260914-m97
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+autonomous: true
+requirements: [QUICK-260914-M97]
+
+files_modified:
+ - apps/api/prisma/schema.prisma
+ - apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql
+ - apps/api/src/settings/settings.service.ts
+ - apps/api/src/settings/settings.service.spec.ts
+ - apps/api/src/settings/dto/smtp-config.dto.ts
+ - apps/api/src/mail/mail.service.ts
+ - apps/api/src/mail/mail.service.spec.ts
+ - apps/api/src/bug-reports/bug-reports.module.ts
+ - apps/api/src/bug-reports/bug-reports.controller.ts
+ - apps/api/src/bug-reports/bug-reports.service.ts
+ - apps/api/src/bug-reports/dto/bug-report.dto.ts
+ - apps/api/src/bug-reports/bug-reports.service.spec.ts
+ - apps/api/src/bug-reports/bug-reports.controller.spec.ts
+ - apps/api/src/app.module.ts
+ - docs/mandantentrennung-zugriffsklassifikation.md
+ - docker-compose.prod.yml
+ - apps/web/package.json
+ - pnpm-lock.yaml
+ - apps/web/src/lib/error-buffer.ts
+ - apps/web/src/lib/error-buffer.test.ts
+ - apps/web/src/lib/bug-report-api.ts
+ - apps/web/src/components/bug-report/bug-report-button.tsx
+ - apps/web/src/components/bug-report/bug-report-dialog.tsx
+ - apps/web/src/components/bug-report/bug-report-button.test.tsx
+ - apps/web/src/components/layout/header.tsx
+ - apps/web/src/components/layout/app-shell.tsx
+ - apps/web/src/lib/settings-api.ts
+ - apps/web/src/components/settings/smtp-settings-form.tsx
+ - apps/web/src/components/settings/smtp-settings-form.test.tsx
+ - apps/web/src/messages/de.json
+ - apps/web/src/messages/en.json
+ - apps/web/src/messages/umlaut-dictionary.ts
+ - docs/anleitung-administration.md
+ - docs/anleitung-anwender.md
+ - docs/anleitung-betrieb.md
+
+estimate:
+ tokens: 150000
+ raw_tokens: 150000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "Jeder angemeldete Anwender sieht in der Kopfzeile rechts, VOR dem Erscheinungsbild-Schalter, einen Symbol-Knopf mit `aria-label`/`title` „Fehler melden“ (gleicher Stil wie `ThemeToggle`). Ein Klick nimmt SOFORT ein Bild der aktuellen Seite auf (html-to-image `toPng(document.body, ...)`, laengste Kante hoechstens 1600 px, `pixelRatio: 1`, `skipFonts: true`) — zu diesem Zeitpunkt existiert im DOM noch KEIN `role=\"dialog\"` (Komponententest pinnt das im Mock von `toPng`). Erst danach oeffnet sich der Dialog mit Bildvorschau (``, `max-h-48`), Haekchen „Bildschirmfoto beifügen“ (vorbelegt an), optionalem Feld „Was ist passiert?“ (maxLength 4000) und den Knoepfen „Abbrechen“/„Senden“; Escape schliesst, der Fokus liegt im Dialog. Schlaegt die Aufnahme fehl, oeffnet sich der Dialog trotzdem, mit dem Hinweis „Kein Bildschirmfoto möglich“ und abgeschaltetem Haekchen."
+ - "„Senden“ schickt `multipart/form-data` an `POST /bug-reports` (mit Cookie, `credentials: 'include'`): Felder `description`, `page` (Pfad + Suchteil, ohne Host), `webVersion`/`webChannel`/`webCommit` (aus `apps/web/src/lib/app-version.ts`, 260914-ku1), `userAgent`, `viewport` (`x`), `clientTime` (ISO), wiederholtes Feld `errors` (je Eintrag `[] : `, hoechstens 20 aus dem Ringpuffer) und — nur bei gesetztem Haekchen — die Datei `screenshot` (PNG-Blob). Erfolg zeigt „Vielen Dank, die Meldung wurde gesendet.“; Fehler zeigen je Statuscode eine deutsche/englische Meldung: 409 „kein Postfach eingerichtet“ (fuer ADMIN/SUPER_ADMIN zusaetzlich der Hinweis mit Link auf `/admin/smtp`, Feld „Fehlermeldungen an“), 429 „zu viele Meldungen“, 413 „Bild zu gross“, 502 „E-Mail konnte nicht gesendet werden“, sonst allgemein. Der Anwender erfaehrt IMMER, ob sein Bericht ankam (kein stilles Verschlucken wie beim Kennwort-Reset)."
+ - "Die API-Route `POST /bug-reports` (Modul `apps/api/src/bug-reports/`) steht JEDEM angemeldeten Benutzer offen (kein `@Roles`, globaler JwtAuthGuard), nimmt das Bild per `FileInterceptor('screenshot', { limits: { fileSize: 4 MiB, files: 1 } })` entgegen (multer 2.1.1 ueber `@nestjs/platform-express` — dasselbe Muster wie der Avatar-Upload in `user.controller.ts`; `main.ts` bleibt UNVERAENDERT, kein globales Body-Limit), nimmt Mandant und Benutzer AUSSCHLIESSLICH aus `@CurrentUser()`, prueft die PNG-Signatur `89 50 4E 47 0D 0A 1A 0A` (sonst 400), drosselt auf 5 Berichte je Benutzer je 10 Minuten (sechster -> 429), begrenzt im DTO (`description` <= 4000, `errors` <= 30 Eintraege je <= 1000 Zeichen, `page` <= 2000, `userAgent` <= 1000) und antwortet 409 mit klarer deutscher Meldung, wenn weder `SmtpConfig.bugReportRecipient` des Sitzungs-Mandanten noch `TESSERA_BUGREPORT_TO` gesetzt ist. Versandfehler -> 502 „E-Mail konnte nicht gesendet werden“. Kein Speichern in der Datenbank; EINE Protokollzeile (Benutzer, Mandant, Seite, Empfaenger, Bildgroesse in Bytes — nie Bild, nie Beschreibung)."
+ - "Die E-Mail geht ueber den Transport des Sitzungs-Mandanten (`MailService.resolveTransport`, 260914-eym) an den eingestellten Empfaenger: Betreff `[Tessera Fehlermeldung] - `, Text-Rumpf in Alltagssprache mit Beschreibung, Seite, Server- und Browserzeit, Benutzer (Anzeigename, Benutzername, Rolle, E-Mail aus der gebundenen Datenbankzeile — nicht aus dem Rumpf), Mandant, Web-Version/Kanal/Commit, API-Version (`formatAppVersionLine()` aus `apps/api/src/health/app-version.ts`), Browser, Fenstergroesse, Liste der letzten Fehlermeldungen, Hinweis auf den Anhang; Anhang `fehlermeldung-.png` (`contentType: image/png`, Inhalt = der hochgeladene Buffer). Spec pinnt die `sendMail`-Argumente mit einem echten 1x1-PNG-Buffer bei gemocktem nodemailer."
+ - "Administratoren stellen den Empfaenger unter **Administrator -> SMTP** im neuen Feld „Fehlermeldungen an“ (optional, `type=\"email\"`, Hinweistext) ein; `PUT /settings/smtp` validiert es per `@IsOptional() @IsEmail()` (`null` loescht, fehlendes Feld bewahrt den gespeicherten Wert), `GET /settings/smtp` liefert es ueber `SMTP_SAFE_SELECT`. Spalte `SmtpConfig.bugReportRecipient String?` per neuer additiver Migration `20260914170000_smtp_config_bug_report_recipient` (lokal per `apps/api/node_modules/.bin/prisma migrate deploy` gegen `tessera-ctl-db-1` eingespielt, `migrate status` „up to date“, `migrate diff` leer). Rueckfall-Variable `TESSERA_BUGREPORT_TO` in `docker-compose.prod.yml` als `${TESSERA_BUGREPORT_TO:-}` durchgereicht; Leerstring zaehlt wie ungesetzt."
+ - "Im Browser laeuft seit `app-shell.tsx` ein Fehlerpuffer (`apps/web/src/lib/error-buffer.ts`, Ringpuffer 20, einmalig installiert, SSR-sicher): `window` `error`, `unhandledrejection`, `console.error` (Original wird weiter aufgerufen) und ein `window.fetch`-Wrapper, der NUR bei `!response.ok` ` -> ` plus die ersten 200 Zeichen des ANTWORT-Rumpfs notiert — nie den Anfrage-Rumpf, nie Cookies, nie Kopfzeilen; Tests pinnen: ok-Antworten werden nicht notiert, der Suchteil fehlt, der Anfrage-Rumpf taucht nicht auf, Installation ist idempotent."
+ - "Falsifizierungen als Specs: (a) sechster Bericht in 10 Minuten -> 429, nach 10 Minuten (Fake-Timer) wieder 200; (b) manipulierte Bilddatei (kein PNG-Kopf) -> 400, `sendBugReport` nie gerufen; (c) weder Feld noch Variable -> 409, `sendBugReport` nie gerufen; Leerstring in der Variable zaehlt als ungesetzt; (d) Rumpf mit fremdem Mandanten-Feld -> `ValidationPipe({ whitelist: true, transform: true })` entfernt das Feld (Pipe-Test mit `metatype: BugReportDto`), und der Dienst ruft `getBugReportRecipient`, `forTenant` und `sendBugReport` ausschliesslich mit der Sitzungs-Mandantenkennung."
+ - "Handbuecher: `docs/anleitung-anwender.md` neuer Abschnitt „Einen Fehler melden“ (Ablauf, was mitgeschickt wird, Datenschutz-Hinweis: das Bild zeigt die aktuelle Seite so wie Sie sie sehen; Kopfleisten-Beschreibung nennt jetzt drei Bedienelemente); `docs/anleitung-administration.md` Kapitel 6 SMTP nennt das Feld „Fehlermeldungen an“ und die Fehlersuche-Tabelle den Fall „Fehler melden antwortet, es sei kein Postfach eingerichtet“; `docs/anleitung-betrieb.md` Kapitel 3 Konfigurationstabelle bekommt `TESSERA_BUGREPORT_TO` als Rueckfall (mit Hinweis, dass die Serverdatei `/opt/tessera/docker-compose.prod.yml` die Zeile von Hand braucht, Kapitel 9 Muster). Echte Umlaute wie im Bestand aller drei Dateien."
+ - "Baseline am Ende: API `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` (Planungszeit 65/1060 plus 3 + 2 + 8 + 3 neue), Web `Test Files 43 passed (43)` / `Tests 255 passed (255)` (Planungszeit 40/243 plus 4 + 6 + 2 neue), `tsc --noEmit` in api, web und shared je Exit 0; `pnpm install --frozen-lockfile` Exit 0; `git diff --stat 5c42c55 -- . ':!.planning'` nennt genau `35 files changed`; `apps/api/src/main.ts`, `.env*`, `biome.json` und alle 36 bestehenden Migrationsordner unangetastet; drei Commits mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet."
+ artifacts:
+ - "apps/api/prisma/schema.prisma — `bugReportRecipient String?` in `model SmtpConfig` hinter `fromAddress` (Kommentar: Postfach fuer den Fehler-melden-Knopf, quick-260914-m97)"
+ - "apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql — Kopfkommentar in der ASCII-Form von `20260909120000_user_email_optional`, dann `ALTER TABLE \"SmtpConfig\" ADD COLUMN \"bugReportRecipient\" TEXT;`"
+ - "apps/api/src/settings/settings.service.ts — `SMTP_SAFE_SELECT` um `bugReportRecipient: true`; `saveSmtpConfig` schreibt das Feld nur, wenn es im DTO vorhanden ist (`null` -> NULL); NEU `getBugReportRecipient(tenantId): Promise` (EIN gebundener Klient, `findUnique` mit `select: { bugReportRecipient: true }`)"
+ - "apps/api/src/settings/dto/smtp-config.dto.ts — `@IsOptional() @IsEmail() bugReportRecipient?: string | null`"
+ - "apps/api/src/mail/mail.service.ts — Typ `OutgoingMail { to, subject, text, html?, attachments? }` mit nodemailer-Anhangsform `{ filename, content: Buffer, contentType }`; privater Kern `deliver(tenantId, mail, kind)` WIRFT; `sendViaTenantTransport` bleibt der verschluckende Mantel um `deliver` (Verhalten fuer Kennwort-Reset/Willkommen identisch, bestehende 4 Tests gruen); NEU `sendBugReport(tenantId, to, report: { subject, text, attachments })` ruft `deliver` direkt und laesst Fehler durch"
+ - "apps/api/src/bug-reports/bug-reports.module.ts — importiert `SettingsModule` und `MailModule` (PrismaModule ist `@Global()`); Controller + Service; in `app.module.ts` hinter `TendersModule` eingetragen"
+ - "apps/api/src/bug-reports/dto/bug-report.dto.ts — `BugReportDto` mit class-validator-Grenzen und `@Transform` (class-transformer, Vorlage `tenders/dto/tender-query.dto.ts`) fuer `errors` (undefined -> [], Einzelwert -> [Einzelwert], Array -> Array); KEIN Mandanten- oder Benutzerfeld"
+ - "apps/api/src/bug-reports/bug-reports.controller.ts — `@Controller('bug-reports')`, `@Post()` ohne `@Roles`, `@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }))`, Parameter `@CurrentUser() user`, `@Body() dto: BugReportDto`, `@UploadedFile() file`; Rueckgabe `{ sent: true }`"
+ - "apps/api/src/bug-reports/bug-reports.service.ts — `submit(user, dto, file)`: Drossel (`Map`, Fenster 600000 ms, max 5, `HttpException(..., HttpStatus.TOO_MANY_REQUESTS)`), Empfaenger (`getBugReportRecipient(user.tenantId)` sonst `ConfigService.get('TESSERA_BUGREPORT_TO')` mit `||`, sonst `ConflictException`), PNG-Signatur (`Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])`, `BadRequestException`), Benutzerzeile ueber `const tenantPrisma = forTenant(this.prisma, user.tenantId) as any` + `user.findUnique({ where: { id: user.id }, select: { username, displayName, email, role } })` (null -> Sitzungswerte), Betreff/Text/Anhang bauen, `mailService.sendBugReport` (Fehler -> `BadGatewayException('E-Mail konnte nicht gesendet werden')`), eine Logger-Zeile"
+ - "apps/api/src/bug-reports/bug-reports.service.spec.ts — NEU, 8 Tests (Happy Path mit echtem 1x1-PNG, ohne Bild, Drossel a, PNG b, Empfaenger c inkl. Leerstring-Variable, Umgebungs-Rueckfall, 502, Mandant aus Sitzung d)"
+ - "apps/api/src/bug-reports/bug-reports.controller.spec.ts — NEU, 3 Tests (Pipe: whitelist entfernt Fremdfeld + `errors`-Normalisierung; Pipe: Grenzen 31 Eintraege / 4001 Zeichen -> BadRequestException; kein `ROLES_KEY`-Metadatum auf `submit`)"
+ - "docs/mandantentrennung-zugriffsklassifikation.md — neue Zeile `| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | ... |` in der Bestandsaufnahme-Tabelle (Pflicht: `rls-access-inventory.spec.ts` prueft jede (Datei, Modell)-Fundstelle) und eine Zeile `bug-reports | 0 | 1 | 0` in der Bereichs-Tabelle"
+ - "docker-compose.prod.yml — `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` im `environment`-Block von `api` hinter `TESSERA_SMTP_FROM`, mit Kommentar im Ton der Datei"
+ - "apps/web/package.json — `\"html-to-image\": \"1.11.13\"` (exakt gepinnt) unter `dependencies`; `pnpm-lock.yaml` entsprechend"
+ - "apps/web/src/lib/error-buffer.ts — `BufferedError { at, kind, message }`, `recordError`, `getRecentErrors`, `formatErrorsForReport`, `installErrorBuffer` (Guard-Symbol auf `window`, `typeof window === 'undefined'` -> no-op)"
+ - "apps/web/src/lib/bug-report-api.ts — `captureScreenshot(): Promise` (Data-URL; `toPng` aus `html-to-image` mit `pixelRatio: 1`, `skipFonts: true`, `cacheBust: true`, `canvasWidth/canvasHeight` = Body-Masse * min(1, 1600 / laengste Kante)), `dataUrlToBlob(dataUrl): Blob` (atob, kein fetch), `sendBugReport(payload): Promise<{ ok: true } | { ok: false; status: number }>` (FormData, `credentials: 'include'`, KEIN Content-Type-Header)"
+ - "apps/web/src/components/bug-report/bug-report-button.tsx — `'use client'`, Symbol-Knopf (Kaefer-Symbol als Inline-SVG 20x20 im Stil des ThemeToggle), Zustand `capturing`, ruft `captureScreenshot()` VOR dem Oeffnen, rendert `BugReportDialog`"
+ - "apps/web/src/components/bug-report/bug-report-dialog.tsx — Muster `marketplace/components/ActivationDialog.tsx` (`role=\"dialog\"`, `aria-modal`, Escape, Fokus), Zustaende `ready | sending | sent | failed`, Props `open`, `screenshot: string | null`, `isAdmin`, `onClose`"
+ - "apps/web/src/components/bug-report/bug-report-button.test.tsx — NEU, 6 Tests, `html-to-image` per `vi.mock` (jsdom kann `toPng` nicht — gemessen: `HTMLVideoElement is not defined` / kein Canvas-Backend)"
+ - "apps/web/src/lib/error-buffer.test.ts — NEU, 4 Tests"
+ - "apps/web/src/components/layout/header.tsx — `` unmittelbar VOR `` (Zeile 112)"
+ - "apps/web/src/components/layout/app-shell.tsx — `useEffect(() => { installErrorBuffer(); }, [])`"
+ - "apps/web/src/lib/settings-api.ts — `bugReportRecipient: string | null` in `SmtpConfig`, `bugReportRecipient?: string | null` in `SaveSmtpPayload`"
+ - "apps/web/src/components/settings/smtp-settings-form.tsx — Feld „Fehlermeldungen an“ (`id=\"smtp-bug-report-recipient\"`, `type=\"email\"`, optional, Hinweistext) hinter der Absenderadresse; Payload traegt `bugReportRecipient: form.bugReportRecipient.trim() || null`"
+ - "apps/web/src/components/settings/smtp-settings-form.test.tsx — NEU, 2 Tests (Feld vorbelegt aus GET; PUT-Payload traegt Wert bzw. `null`)"
+ - "apps/web/src/messages/de.json + en.json — Namensraum `bugReport` (Knopf, Dialog, Meldungen) und `settings.smtp.bugReportRecipient` / `bugReportRecipientHelp`, in beiden Dateien an derselben Stelle; `umlaut-dictionary.ts` `UMLAUT_ALLOWLIST` um die vom Waechter genannten korrekten Woerter (erwartet mindestens `passiert`)"
+ - "docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md — Abschnitte wie in den truths"
+ key_links:
+ - "Reihenfolge im Knopf: `captureScreenshot()` MUSS abgeschlossen sein, bevor `open` auf true geht — sonst ist der Dialog im Bild. Der Test pinnt das, indem der `toPng`-Mock waehrend seines Aufrufs `screen.queryByRole('dialog')` auf `null` prueft."
+ - "Multipart statt JSON+Base64: `main.ts` bleibt unangetastet, das Limit gilt nur fuer diese Route (`fileSize` -> multer `LIMIT_FILE_SIZE` -> Nest `PayloadTooLargeException` 413, gemessen in `platform-express/multer/multer.utils.js`). Gemessen ebenfalls: `app.useBodyParser('json', { limit })` wuerde in Nest 11 + Express 5 funktionieren (`registerParserMiddleware` ueberspringt einen bereits registrierten `jsonParser`), ist aber eine globale DoS-Flaeche fuer JEDE JSON-Route inkl. `/auth/login` — deshalb verworfen."
+ - "multer + `append-field` (gemessen): ein einzelnes Feld `errors` kommt als STRING, zwei oder mehr als Array, keins als undefined — deshalb `@Transform` im DTO, sonst faellt `@IsArray()` bei genau einer Fehlermeldung. Der Controller-Spec pinnt die Normalisierung ueber `new ValidationPipe({ whitelist: true, transform: true }).transform(...)`."
+ - "Der Empfaenger lebt in `SmtpConfig` — ohne gespeicherte SMTP-Einstellungen gibt es das Feld nicht (dann greift NUR `TESSERA_BUGREPORT_TO` zusammen mit der Umgebungs-SMTP-Kette). Das ist Absicht: ohne Transport gibt es ohnehin keine E-Mail."
+ - "`rls-access-inventory.spec.ts` scheitert, sobald `bug-reports.service.ts` auf `user` zugreift und die Doku-Zeile fehlt; die Zuweisungsform `const tenantPrisma = forTenant(` ist Pflicht (Erkennungsform 2)."
+ - "`umlaut-guard.spec.ts` flaggt in de.json jedes Wort mit `ae/oe/ue/ss` ausserhalb `UMLAUT_ALLOWLIST` — „Was ist passiert?“ (Pflichtlabel aus dem Auftrag) enthaelt `ss`; die Meldung des Tests nennt den Fix (Allowlist)."
+ - "`html-to-image` 1.11.13 rendert ueber SVG `foreignObject` (der Browser rastert selbst) — deshalb funktionieren OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert; kein Webfont im Projekt (`globals.css` nennt „Inter“ nur als System-Schriftfamilie, keine `@font-face`), deshalb `skipFonts: true` ohne sichtbaren Unterschied."
+ - "Lokaler Mailserver EXISTIERT: `docker-compose.dev.yml` fuehrt `mailhog` (Ports 1025/8025, Abbild `mailhog/mailhog:latest` liegt lokal vor) — der Auftrag nahm an, es gaebe keinen. Der Human-Check kann die echte E-Mail mit PNG-Anhang unter `http://localhost:8025` sehen."
+---
+
+
+Fehler-melden-Knopf in der Kopfzeile: Ein Klick nimmt SOFORT ein Bild der aktuellen Seite auf (bevor ein Dialog darueberliegt), dann oeffnet sich ein kleiner Dialog mit Vorschau, optionalem Feld „Was ist passiert?", Haekchen „Bildschirmfoto beifügen" (an) und „Senden". Senden schickt Bild, Beschreibung, Seite, Web-/API-Version mit Kanal und Commit, Browser, Fenstergroesse, Zeitpunkt, angemeldeten Benutzer und die letzten Fehlermeldungen des Browsers als E-Mail mit PNG-Anhang an ein Postfach, das der Administrator unter Administrator -> SMTP im neuen Feld „Fehlermeldungen an" einstellt (Rueckfall: `TESSERA_BUGREPORT_TO`).
+
+Purpose: Morgen (2026-09-15) geht Tessera live. Der User (kein Programmierer, betreibt die Installation) will Fehler der Anwender mit Bild und Kontext in sein Postfach bekommen, ohne Rueckfragen stellen zu muessen. Die Versionsangabe im Bericht ist der Grund, warum 260914-ku1 vorher gebaut wurde.
+
+Output: 35 Dateien (16 API/Compose/Doku-Tabelle, 16 Web inkl. Lockfile, 3 Handbuecher), drei Commits mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet. Danach (nicht in diesem Plan): Erstfreigabe v1.0.0 durch den Orchestrator.
+
+
+
+@~/.claude/gsd-core/workflows/execute-plan.md
+@~/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-SUMMARY.md
+@apps/web/src/components/layout/header.tsx
+@apps/web/src/components/theme-toggle.tsx
+@apps/web/src/components/layout/app-shell.tsx
+@apps/web/src/lib/app-version.ts
+@apps/web/src/lib/settings-api.ts
+@apps/web/src/components/settings/smtp-settings-form.tsx
+@apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx
+@apps/web/src/components/layout/sidebar.test.tsx
+@apps/web/src/messages/umlaut-guard.spec.ts
+@apps/api/src/mail/mail.service.ts
+@apps/api/src/mail/mail.service.spec.ts
+@apps/api/src/settings/settings.service.ts
+@apps/api/src/settings/settings.service.spec.ts
+@apps/api/src/settings/dto/smtp-config.dto.ts
+@apps/api/src/user/user.controller.ts
+@apps/api/src/tenders/dto/tender-query.dto.ts
+@apps/api/src/health/app-version.ts
+@apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql
+@apps/api/src/prisma/rls-access-inventory.spec.ts
+@docs/anleitung-anwender.md
+@docs/anleitung-administration.md
+@docs/anleitung-betrieb.md
+
+
+Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin/main) gemessen — die Ausfuehrung misst erneut; diese Zahlen sind der Bezugspunkt der Gates:
+
+- Baseline frisch nachgemessen: API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)`; Web `Test Files 40 passed (40)` / `Tests 243 passed (243)`; `tsc --noEmit` in `packages/shared`, `apps/api`, `apps/web` je Exit 0. Lokal laeuft nur `tessera-ctl-db-1` (IP `172.19.0.2`); `prisma migrate status` mit `postgresql://tessera:tessera_dev@172.19.0.2:5432/tessera` -> „36 migrations found … Database schema is up to date!"; `prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script` -> `-- This is an empty migration.` (Prisma 6.19.3). Kein Web-/API-Prozess auf 3000/3001. Ein CI-Lauf (Task 691, Build & Publish) lief gerade fuer `5c42c55`.
+- **Bibliothek:** `pnpm view html-to-image version` -> `1.11.13` (dist-tag `latest`, veroeffentlicht 2025-02-14, MIT, `dependencies: {}`, `peerDependencies: {}`, `lib/index.d.ts`, Repo `github.com/bubkoo/html-to-image`, 4.822.813 Downloads in der Woche 2026-09-05..11 laut `api.npmjs.org`). Optionen in `types.d.ts` bestaetigt: `pixelRatio`, `canvasWidth`, `canvasHeight`, `skipFonts`, `cacheBust`, `filter`, `fetchRequestInit`. Skalierung bestaetigt in `lib/index.js` 88-102: `canvas.width = canvasWidth * ratio`, `drawImage(img, 0, 0, canvas.width, canvas.height)` — `canvasWidth/canvasHeight` skalieren das Bild. `util.js` nutzt `foreignObject`. **Wegwerf-Skript im Scratchpad (`h2i/`, jsdom 29.1.1 des Projekts): `toPng` scheitert in jsdom** (`HTMLVideoElement is not defined`, danach `Element is not defined`; ohne Canvas-Backend ohnehin kein Rastern) -> im Komponententest ist `vi.mock('html-to-image')` Pflicht, der Bildbeweis kommt aus dem Browser (Human-Check).
+- **Body-Parser-Entscheidung (gemessen, nicht angenommen):** `FileInterceptor` + multer 2.1.1 sind ueber `@nestjs/platform-express@11.1.27` installiert und in `user.controller.ts` (Avatar, `limits.fileSize`) produktiv — Multipart ist der bewaehrte Weg fuer Binaerdaten mit Limit je Route. `platform-express/multer/multer.utils.js` bildet `LIMIT_FILE_SIZE` auf `PayloadTooLargeException` (413) ab. `append-field@1.0.0` (multer): `errors` einmal -> `\"a\"` (String), dreimal -> `[\"a\",\"b\",\"c\"]`. Alternative gemessen: `app.useBodyParser('json', { limit: '8mb' })` funktioniert in Nest 11/Express 5 ohne `bodyParser: false` (`NestApplication.useBodyParser` -> `ExpressAdapter.useBodyParser` -> `this.use(express.json(...))`; `registerParserMiddleware` filtert per `isMiddlewareApplied('jsonParser')`, und `express.json()` heisst tatsaechlich `jsonParser`) — aber global fuer jede JSON-Route inkl. der oeffentlichen `/auth/login`. Entscheidung: Multipart, `main.ts` unangetastet.
+- **Nest-Ausnahmen:** es gibt KEINE `TooManyRequestsException` in `@nestjs/common` -> `new HttpException('...', HttpStatus.TOO_MANY_REQUESTS)`. Keine Drossel-Bibliothek im Projekt (`@nestjs/throttler` nicht installiert) -> In-Memory-Map im Dienst.
+- `@CurrentUser()` liefert `{ id, username, role, tenantId }` (jwt.strategy.ts 27-33) — KEIN Anzeigename, keine E-Mail. Deshalb eine gebundene `user.findUnique`-Zeile im Dienst (`User` hat `username`, `email String?`, `displayName String?`, `role`). Folge: `rls-access-inventory.spec.ts` (Erkennung 2: `const = forTenant(`; Tabellenzeilen-Muster `| apps/api/src/… | modell | klasse | stand |`) verlangt eine neue Zeile in `docs/mandantentrennung-zugriffsklassifikation.md`, sonst rot. `PrismaModule` ist `@Global()`.
+- Muster „nur angemeldet": `user.controller.ts` 273-275 — kein `@Roles()`, `RolesGuard.canActivate()` liefert true bei leerer Rollenliste, `JwtAuthGuard` global (`app.module.ts` 52-56). `Roles`-Metadatum ueber `ROLES_KEY` aus `auth/decorators/roles.decorator`.
+- `MailService.sendViaTenantTransport` verschluckt Fehler bewusst (T-02-12); fuer den Bericht darf das nicht gelten -> Kern `deliver` wirft, der Mantel bleibt. `mail.service.spec.ts` mockt `nodemailer` (`createTransport` -> `{ sendMail, close }`), 4 Tests, `mockSendMail` je Test neu.
+- `settings.service.spec.ts`: Fake-Prisma mit `__makeBoundClient(tenantId)`, gebundener Klient bietet nur `findUnique`/`upsert` (mit `applySelect`), Test „genau EIN gebundener Klient je Aufruf" (Zeile 433) — `getBugReportRecipient` haelt das. Test Zeile 206 prueft, dass `update` KEINEN Schluessel `encryptedPassword` traegt, wenn kein Kennwort kam — dieselbe Form (bedingtes Spreading) fuer `bugReportRecipient`. `settings.controller.ts` braucht KEINE Aenderung (DTO + SAFE_SELECT tragen das Feld).
+- SMTP-Formular liegt unter `/admin/smtp` (`app/(portal)/admin/smtp/page.tsx`, Header-Dropdown „Administrator" -> „SMTP", Handbuch „Administrator -> SMTP") — NICHT „Einstellungen -> E-Mail" wie im Auftrag; der Plan folgt dem Bestand. `smtp-settings-form.tsx` hat keinen Test.
+- Web: `auth-actions.ts` und `module-access-actions.ts` sind Server Actions (`'use server'`, laufen auf dem Next-Server) — ein Browser-`fetch`-Wrapper sieht sie NICHT; alle uebrigen 28 Dateien mit `fetch(` rufen `${NEXT_PUBLIC_API_URL}/…` direkt aus dem Browser (`credentials: 'include'`) -> der Wrapper in `error-buffer.ts` erfasst genau diese. Kein Test rendert `Header` oder `AppShell` (kein Mock-Nachziehen noetig). Avatar-Bild `/api-proxy/users/me/avatar` ist same-origin.
+- Dialog-Muster im Bestand: `marketplace/components/ActivationDialog.tsx` (`fixed inset-0 z-50 … bg-black/50`, `role=\"dialog\" aria-modal=\"true\"`, Escape -> `onCancel`, Fokus auf ersten Knopf, Tab-Falle). Uebersetzungs-Mock: `sidebar.test.tsx` 16-41. `useAuthStore` ist ein Zustand-Store (`useAuthStore((s) => s.user)` moeglich); Rollen `SUPER_ADMIN | ADMIN | USER`.
+- i18n: `de.json`/`en.json` je 963 Zeilen, Namensraeume `common, auth, header, sidebar, dashboard, settings, widgets, admin, adminModules, theme, locale, modules, …`; `settings.smtp` traegt heute 17 Schluessel (`title … testFailed`). `umlaut-guard.spec.ts`: Tokenizer `/[A-Za-zÄÖÜäöüß]+/g`, `SUSPECT_RE = /(ae|oe|ue|ss)/i`, `UMLAUT_ALLOWLIST` (139 Eintraege, u. a. `Adresse`, `muss`, `lassen`, `aktuelle`, `erfasst`) — NICHT enthalten: `passiert`, `dass`, `wissen`, `Klasse`, `Prozess`, `Ausschnitt`. Werte mit `@` werden uebersprungen. `tenderRadar-parity.spec.ts` prueft nur `tenderRadar`.
+- Handbuecher: `anleitung-anwender.md` 166 Zeilen (63 mit Umlauten; Kopfleiste Zeilen 41-48 „zwei Bedienelemente"; Abschnitte „Persönliche Einstellungen" ab 141, „Häufige Stolpersteine" ab 158; Inhaltsverzeichnis 6-20); `anleitung-administration.md` 240 Zeilen (96 mit Umlauten; Kapitel 6 SMTP Zeilen 202-213, Fehlersuche-Tabelle ab 227, letzte Zeile 240); `anleitung-betrieb.md` 521 Zeilen (Konfigurationstabelle Kapitel 3 Zeilen 150-162, SMTP-Zeile 160, Kapitel 9 ab 355 mit dem Muster „Serverdatei von Hand ergaenzen").
+- Compose: `docker-compose.prod.yml` `api.environment` Zeilen 36-59 (`TESSERA_SMTP_FROM` Zeile 49). `docker-compose.dev.yml` fuehrt `mailhog` (Ports 1025/8025, `backend-net`); Abbild `mailhog/mailhog:latest` lokal vorhanden; `docker compose -f docker-compose.yml -f docker-compose.dev.yml config --services` -> `phpldapadmin db api web mailhog openldap`. Lokale Abbilder `tessera-ctl-api:latest`/`tessera-ctl-web:latest` vorhanden (Cache-Waerme fuer `docker compose up -d --build api web`).
+- Detektoren: `api-coverage` -> `{\"detected\":false}` (kein externer Dienst — nodemailer und html-to-image sind Bibliotheken); `assumption-delta scan quick-260914-m97` -> `{\"skipped\":true,\"reason\":\"phase_unresolved\"}` (Quick-Task ohne ROADMAP-Abschnitt; inhaltlich keine Einzahl-/Mehrzahl-Verschiebung — ein Empfaenger je Mandant, wie eine SMTP-Konfiguration je Mandant); `schema-gate` FEUERT (`schema.prisma` + neue Migration) -> [BLOCKING]-Schritt `migrate deploy` in Task 1. Konfiguration: `tdd_mode=false` (Task 1 und 2 tragen trotzdem `tdd=\"true\"`), `security_enforcement=true`, ASVS 1, Blocking-Schwelle `high`, `human_verify_mode=end-of-phase`, `branching_strategy: none` (Commits auf `main`, wie 260914-ku1).
+- Paketlegitimitaet (kein RESEARCH.md im Quick-Modus, deshalb hier): `html-to-image@1.11.13` — Registry-Metadaten wie oben, 4,8 Mio. Wochen-Downloads, GitHub `bubkoo/html-to-image`, ein Maintainer, keine Abhaengigkeiten -> **[VERIFIED]** durch Registry-Nachweis zur Planungszeit; kein blockierender Mensch-Checkpoint noetig (Auftrag: kein Nachfragen). `T-M97-SC` im Threat-Register.
+- Biome ist im Bestand nicht lauffaehig (WINDOWS #35) — kein Biome-Gate; `biome.json` unangetastet.
+
+
+
+| Paket | Version | Quelle | Nachweis | Einstufung |
+|---|---|---|---|---|
+| html-to-image | 1.11.13 (exakt) | npm-Registry via `pnpm view` | dist-tag latest, 2025-02-14, MIT, 0 deps, Repo github.com/bubkoo/html-to-image, 4.822.813 Downloads/Woche (api.npmjs.org, 2026-09-05..11), `pnpm view … dependencies` leer | [VERIFIED] |
+
+
+
+
+
+
+ Task 1: API — Empfaenger-Spalte mit Migration, MailService mit Anhaengen, Modul bug-reports (Multipart, Drossel, PNG-Pruefung, Mandant aus der Sitzung) mit Specs und Falsifizierungen (a)-(d)
+ apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql, apps/api/src/settings/settings.service.ts, apps/api/src/settings/settings.service.spec.ts, apps/api/src/settings/dto/smtp-config.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/bug-reports/bug-reports.module.ts, apps/api/src/bug-reports/bug-reports.controller.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/bug-reports/dto/bug-report.dto.ts, apps/api/src/bug-reports/bug-reports.service.spec.ts, apps/api/src/bug-reports/bug-reports.controller.spec.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, docker-compose.prod.yml
+ `docker ps --format '{{.Names}}' | grep -c '^tessera-ctl-db-1$'` liefert `1`, und `cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" ./node_modules/.bin/prisma migrate status` endet mit `Database schema is up to date!` (sonst zuerst die lokale Datenbank in Ordnung bringen — ohne sie ist der [BLOCKING]-Schritt nicht ausfuehrbar).
+
+ `apps/api/src/settings/settings.service.spec.ts` (+3, im Stil der Datei, `FakeSmtpRow` bekommt `bugReportRecipient?: string | null`):
+ - Test A (`getBugReportRecipient`): Zeile fuer `t1` mit `bugReportRecipient: 'fehler@a.example.invalid'` -> `await service.getBugReportRecipient('t1')` liefert genau diese Zeichenkette; `expectBoundCall(prisma, 't1', 'findUnique')`; fuer `t2` (keine Zeile) `null`; Zeile mit `bugReportRecipient: null` -> `null`.
+ - Test B (`saveSmtpConfig` mit Feld): DTO mit `bugReportRecipient: 'fehler@a.example.invalid'` -> die gespeicherte Zeile (`prisma.__configs.get('t1')`) traegt den Wert; Rueckgabe (SAFE_SELECT) enthaelt `bugReportRecipient` und KEIN `encryptedPassword`.
+ - Test C (`saveSmtpConfig` ohne Feld bewahrt, `null` loescht): erst mit Wert speichern, dann DTO OHNE `bugReportRecipient` -> Wert bleibt; dann DTO mit `bugReportRecipient: null` -> Wert ist `null`.
+ `apps/api/src/mail/mail.service.spec.ts` (+2):
+ - Test 5 (`sendBugReport` reicht Anhaenge durch): `sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [{ filename: 'x.png', content: Buffer.from([1,2,3]), contentType: 'image/png' }] })` -> `mockSendMail` genau einmal mit `to`, `subject`, `text` und `attachments[0]` (`filename`, `contentType`, `content` per `Buffer.equals`), `from` = fromAddress von configA, `mockClose` gerufen.
+ - Test 6 (Fehler werden NICHT verschluckt): `mockSendMail` lehnt ab -> `await expect(service.sendBugReport(...)).rejects.toThrow()`, `mockClose` trotzdem gerufen; Gegenprobe im selben Test: `sendPasswordResetEmail` mit demselben ablehnenden Mock loest NICHT aus (T-02-12 unveraendert).
+ `apps/api/src/bug-reports/bug-reports.service.spec.ts` (NEU, 8 Tests; `vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId)) }))` wie in `user.controller.spec.ts`; Fake-Prisma mit `user.findUnique` je gebundenem Klient, das nur Zeilen des eigenen Mandanten liefert; Attrappen `settingsService.getBugReportRecipient`, `mailService.sendBugReport`, `configService.get`; `vi.stubEnv('APP_VERSION', 'v9.9.9')` + `APP_CHANNEL=live` fuer die API-Zeile; Konstante `PNG_1x1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')`; Sitzungsbenutzer `{ id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' }`; Basis-DTO `{ page: '/admin/users?tab=x', description: 'Knopf tut nichts', webVersion: 'v1.2.3', webChannel: 'beta', webCommit: 'abc1234', userAgent: 'UA', viewport: '1920x1080', clientTime: '2026-09-14T10:00:00.000Z', errors: ['[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}'] }`):
+ - Test 1 (Happy Path mit Bild): Empfaenger aus Settings -> `sendBugReport` genau einmal mit `('t1', 'fehler@a.example.invalid', report)`; `report.subject === '[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x'`; `report.text` enthaelt `Knopf tut nichts`, `/admin/users?tab=x`, `Anna Muster (anna)` (displayName aus der Fake-Zeile), `USER`, `anna@a.example.invalid`, `t1`, `v1.2.3 (beta) abc1234`, `Tessera API v9.9.9 (live)`, `UA`, `1920x1080`, die Fehlerzeile und `Bildschirmfoto: im Anhang`; `report.attachments` hat genau einen Eintrag mit `filename` passend zu `/^fehlermeldung-\d{8}-\d{4}\.png$/`, `contentType: 'image/png'`, `content.equals(PNG_1x1)`; Rueckgabe `{ sent: true }`.
+ - Test 2 (ohne Bild): `file` undefined -> `attachments` ist leer (Array der Laenge 0) und der Text enthaelt `Bildschirmfoto: nicht beigefügt`; ohne `description` steht `(keine Beschreibung)`.
+ - Test 3 (Falsifizierung a, Drossel): `vi.useFakeTimers()`; fuenf Aufrufe fuer `u1` gelingen, der sechste wirft `HttpException` mit `getStatus() === 429` und `sendBugReport` wurde genau fuenfmal gerufen; ein anderer Benutzer `u2` im selben Moment gelingt; `vi.advanceTimersByTime(600001)` -> `u1` gelingt wieder; `vi.useRealTimers()` im `finally`.
+ - Test 4 (Falsifizierung b, PNG-Signatur): `file = { buffer: Buffer.from('nicht png, aber lang genug'), size: 25, mimetype: 'image/png' }` -> `BadRequestException`, `sendBugReport` NICHT gerufen; auch ein Buffer aus den ersten 7 PNG-Bytes -> 400.
+ - Test 5 (Falsifizierung c, kein Empfaenger): Settings liefert `null`, `configService.get('TESSERA_BUGREPORT_TO')` liefert `undefined` -> `ConflictException`; zweiter Fall im selben Test mit Leerstring `''` -> ebenfalls `ConflictException`; `sendBugReport` in beiden Faellen NICHT gerufen; die Meldung enthaelt `Fehlermeldungen an`.
+ - Test 6 (Umgebungs-Rueckfall): Settings `null`, Variable `'ops@a.example.invalid'` -> `sendBugReport` mit `to === 'ops@a.example.invalid'`; Settings mit Wert UND Variable gesetzt -> das Feld gewinnt.
+ - Test 7 (Versandfehler sichtbar): `sendBugReport` lehnt mit `new Error('ECONNREFUSED')` ab -> `BadGatewayException` mit Meldung `E-Mail konnte nicht gesendet werden`; der Drossel-Zaehler zaehlt den Versuch trotzdem (zweiter Aufruf danach: `sendBugReport` erneut gerufen — kein Sperren durch Fehlversuche verlangt, nur Zaehlen).
+ - Test 8 (Falsifizierung d, Mandant aus der Sitzung): DTO-Objekt zusaetzlich mit `tenantId: 'fremd'` und `userId: 'u-fremd'` (als `any`) -> `getBugReportRecipient` mit `'t1'`, `forTenant` mit `('…', 't1')`, `sendBugReport` mit erstem Argument `'t1'`; die Fake-Zeile fuer `u1` liegt unter `t1`, unter `fremd` liegt eine Zeile mit anderem Anzeigenamen, die im Text NICHT auftaucht.
+ `apps/api/src/bug-reports/bug-reports.controller.spec.ts` (NEU, 3 Tests; `import 'reflect-metadata'`; `ValidationPipe` aus `@nestjs/common`, `ROLES_KEY` aus `../auth/decorators/roles.decorator`):
+ - Test 1 (Pipe: whitelist + errors-Normalisierung): `new ValidationPipe({ whitelist: true, transform: true }).transform({ page: '/x', webVersion: 'v1', webChannel: 'beta', webCommit: '', userAgent: 'UA', viewport: '1x1', clientTime: 't', errors: 'einzeln', tenantId: 'fremd' }, { type: 'body', metatype: BugReportDto })` -> Ergebnis hat KEINE Eigenschaft `tenantId`, `errors` ist `['einzeln']`; ohne `errors` -> `[]`; mit Array bleibt Array.
+ - Test 2 (Pipe: Grenzen): 31 Eintraege in `errors` -> `rejects.toThrow(BadRequestException)`; `description` mit 4001 Zeichen -> BadRequestException; 30 Eintraege und 4000 Zeichen -> gelingt.
+ - Test 3 (nur angemeldet): `Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)` ist `undefined` (kein `@Roles`), und `Reflect.getMetadata('path', BugReportsController) === 'bug-reports'`.
+
+
+ Schritt A — RED: die drei neuen/erweiterten Spec-Dateien aus `` anlegen bzw. ergaenzen (Kopfkommentar deutsch ASCII mit Bezug quick-260914-m97, Testnamen deutsch), BEVOR Produktionscode entsteht. `pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts` muss rot sein (Modul nicht gefunden bzw. Erwartungen verfehlt) — die Ausgabezeilen ins SUMMARY.
+
+ Schritt B — Schema und Migration (danach [BLOCKING]):
+ 1. `schema.prisma`, `model SmtpConfig`: hinter `fromAddress` die Zeile `bugReportRecipient String?` mit Zeilenkommentar (Postfach fuer den Fehler-melden-Knopf, quick-260914-m97; leer = Rueckfall `TESSERA_BUGREPORT_TO`).
+ 2. Neuer Ordner `apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/` mit `migration.sql`: Kopfkommentar in der ASCII-Form von `20260909120000_user_email_optional` (Anlass: Fehler-melden-Knopf; warum in `SmtpConfig` und nicht in einer eigenen Tabelle — der Empfaenger gehoert zum Mailversand des Mandanten, `tenant_isolation_policy` aus 20260909140000 gilt automatisch, keine Systemleseregel noetig, weil die Route mit angemeldetem Benutzer laeuft; additiv, nullable, Bestandszeilen unangetastet; keine bestehende Migration angefasst), dann genau `ALTER TABLE "SmtpConfig" ADD COLUMN "bugReportRecipient" TEXT;`.
+ 3. **[BLOCKING] Schema-Push:** `cd apps/api && DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate deploy` (NICHT `npx prisma`, NICHT `db push`); danach `migrate status` -> `Database schema is up to date!` und `migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script` -> `-- This is an empty migration.`; dann `./node_modules/.bin/prisma generate` (sonst kennt `tsc` das Feld nicht). Alle drei Ausgaben ins SUMMARY. `DATABASE_URL` wird NUR in dieser Shell-Zeile gesetzt — keine `.env`-Datei anfassen.
+
+ Schritt C — Settings:
+ 4. `smtp-config.dto.ts`: `@IsOptional() @IsEmail() bugReportRecipient?: string | null;` mit Kommentar (optional; `null` loescht; Absicht: nur ein Administrator kann das Ziel setzen — T-M97-05).
+ 5. `settings.service.ts`: `SMTP_SAFE_SELECT` um `bugReportRecipient: true`; in `saveSmtpConfig` das `data`-Objekt um `...(dto.bugReportRecipient !== undefined ? { bugReportRecipient: dto.bugReportRecipient || null } : {})` (fehlend = bewahren, `null`/leer = loeschen); NEUE Methode `getBugReportRecipient(tenantId: string): Promise` mit `const tenantPrisma = forTenant(this.prisma, tenantId) as any;` und `findUnique({ where: { tenantId }, select: { bugReportRecipient: true } })` -> `row?.bugReportRecipient ?? null`; JSDoc: Mandantengebunden, ein Klient, Verwender `BugReportsService`.
+
+ Schritt D — MailService:
+ 6. `mail.service.ts`: exportierten Typ `OutgoingMail` (`to`, `subject`, `text`, optional `html`, optional `attachments: { filename: string; content: Buffer; contentType: string }[]`) und `BugReportMail = Pick` anlegen. Den Rumpf von `sendViaTenantTransport` in `private async deliver(tenantId, mail: OutgoingMail, kind): Promise` verschieben (resolveTransport, createTransport, `sendMail({ from, to, subject, text, html, attachments })`, Erfolgs-Log, `close()` im `finally`) — `deliver` WIRFT. `sendViaTenantTransport` wird zum Mantel: `try { await this.deliver(...) } catch (error) { bisheriges error-Log }` — Verhalten fuer Kennwort-Reset/Willkommen unveraendert (Kommentar: T-02-12 bleibt fuer diese beiden Wege). NEU `async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise` -> `await this.deliver(tenantId, { to, ...report }, 'Bug report')` mit JSDoc: Fehler gehen bewusst nach aussen — der Anwender soll wissen, ob sein Bericht ankam (Gegenteil von T-02-12, begruendet). Kopfkommentar der Datei um zwei Saetze ergaenzen.
+
+ Schritt E — Modul bug-reports (Verzeichnis `apps/api/src/bug-reports/`):
+ 7. `dto/bug-report.dto.ts`: Klasse `BugReportDto` — `description?: string` (`@IsOptional() @IsString() @MaxLength(4000)`), `page: string` (`@IsString() @MaxLength(2000)`), `webVersion` (`@IsString() @MaxLength(100)`), `webChannel` (`@IsString() @MaxLength(20)`), `webCommit` (`@IsString() @MaxLength(64)`; Leerstring erlaubt), `userAgent` (`@IsString() @MaxLength(1000)`), `viewport` (`@IsString() @MaxLength(50)`), `clientTime` (`@IsString() @MaxLength(50)`), `errors: string[]` mit `@Transform(({ value }) => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value])` (Import aus `class-transformer`, Vorlage `tender-query.dto.ts` Zeile 160) gefolgt von `@IsArray() @ArrayMaxSize(30) @IsString({ each: true }) @MaxLength(1000, { each: true })`. Kopfkommentar: Multipart-Felder kommen als Strings; ein einzelnes `errors`-Feld kommt als String, mehrere als Array (append-field, gemessen) — daher die Normalisierung; Mandant und Benutzer stehen bewusst NICHT im DTO (T-M97-06), `whitelist: true` der globalen Pipe entfernt Fremdfelder.
+ 8. `bug-reports.service.ts`: `@Injectable() BugReportsService` mit Konstruktor `(settingsService: SettingsService, mailService: MailService, configService: ConfigService, prisma: PrismaService)`; Konstanten `WINDOW_MS = 10 * 60 * 1000`, `MAX_PER_WINDOW = 5`, `PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])`; privates `Map` fuer die Drossel. Oeffentlich `async submit(user: { id: string; username: string; role: string; tenantId: string }, dto: BugReportDto, file?: { buffer: Buffer; size: number; mimetype?: string }): Promise<{ sent: true }>` in dieser Reihenfolge: (1) Drossel pruefen und zaehlen (Zeitstempel aelter als das Fenster verwerfen; bei `>= MAX_PER_WINDOW` `throw new HttpException('Zu viele Fehlermeldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.', HttpStatus.TOO_MANY_REQUESTS)`; sonst `Date.now()` anhaengen — der Versuch zaehlt auch, wenn spaeter der Versand scheitert); (2) Bild pruefen: wenn `file` vorhanden und (`file.buffer.length < 8` oder die ersten 8 Bytes ungleich `PNG_SIGNATURE`) -> `BadRequestException('Das Bildschirmfoto ist keine gültige PNG-Datei.')`; (3) Empfaenger: `(await this.settingsService.getBugReportRecipient(user.tenantId)) || (this.configService.get('TESSERA_BUGREPORT_TO') || '').trim() || null`; fehlt er -> `ConflictException('Für Fehlermeldungen ist noch kein Postfach eingerichtet. Ein Administrator legt es unter Administrator → SMTP im Feld „Fehlermeldungen an" fest.')`; (4) Benutzerzeile: `const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;` dann `tenantPrisma.user.findUnique({ where: { id: user.id }, select: { username: true, displayName: true, email: true, role: true } })` — `null` -> Sitzungswerte (Kommentar: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten; Zeile in `docs/mandantentrennung-zugriffsklassifikation.md`); (5) Betreff `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${dto.page.slice(0, 120)}`; (6) Text als Zeilen-Array mit `join('\n')`: Einleitung „Ein Anwender hat über den Knopf „Fehler melden" eine Meldung geschickt.", Leerzeile, „Was ist passiert?", Beschreibung oder `(keine Beschreibung)`, Leerzeile, dann je eine Zeile `Seite:`, `Zeitpunkt (Server):` (`new Date().toISOString()`), `Zeitpunkt (Browser):`, `Benutzer:` (` (), Rolle , E-Mail `), `Mandant:`, `Web:` (` () `), `API:` (`formatAppVersionLine()` aus `../health/app-version`), `Browser:`, `Fenster:`, Leerzeile, `Letzte Fehlermeldungen im Browser ():` und je Eintrag `- ` oder `- keine`, Leerzeile, `Bildschirmfoto: im Anhang ( Bytes)` bzw. `Bildschirmfoto: nicht beigefügt`; (7) Anhang: bei Bild `[{ filename: 'fehlermeldung-.png' (UTC-Zeit, mit `padStart`), content: file.buffer, contentType: 'image/png' }]`, sonst `[]`; (8) `try { await this.mailService.sendBugReport(user.tenantId, to, { subject, text, attachments }) } catch (error) { this.logger.error('Bug report mail failed', error instanceof Error ? error.stack : String(error)); throw new BadGatewayException('E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.'); }`; (9) genau EINE Logger-Zeile `Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${dto.page.slice(0,120)}, screenshot ${bytes} bytes` (nie Beschreibung, nie Bild); Rueckgabe `{ sent: true }`. Kopfkommentar der Datei (deutsch, ASCII): Zweck, warum Multipart, warum kein Speichern, Drossel-Semantik, Sicherheitsbezuege T-M97-03/04/06.
+ 9. `bug-reports.controller.ts`: `@Controller('bug-reports')`, Methode `submit` mit `@Post()`, `@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }))` (Import aus `@nestjs/platform-express`, wie `user.controller.ts`), Parameter `@CurrentUser() user: any`, `@Body() dto: BugReportDto`, `@UploadedFile() file?: any` -> `return this.service.submit(user, dto, file)`. Kommentar ueber der Klasse: alle angemeldeten Rollen — bewusst KEIN Rollen-Dekorator (Muster `user.controller.ts` Zeile 273-275); Limit je Route statt global (T-M97-03); Mandant nur aus dem Sitzungsnachweis (T-M97-06).
+
+ 10. `bug-reports.module.ts`: `@Module({ imports: [SettingsModule, MailModule], controllers: [BugReportsController], providers: [BugReportsService] })`; in `app.module.ts` Import + Eintrag hinter `TendersModule`.
+ 11. `docs/mandantentrennung-zugriffsklassifikation.md`: in der Bestandsaufnahme-Tabelle (Kopf Zeile 659) eine Zeile `| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |` alphabetisch hinter den `auth/`-Zeilen; in der Bereichs-Tabelle (Kopf Zeile 163) eine Zeile `| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |`. Danach `pnpm -C apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts` gruen.
+ 12. `docker-compose.prod.yml`: im `environment`-Block von `api` hinter `TESSERA_SMTP_FROM` (Zeile 49) die Zeile `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` mit Kommentar im Ton der Datei (englisch wie die Nachbarkommentare): fallback mailbox for the in-app bug report button, empty = only the per-tenant setting in Administrator -> SMTP applies. Sonst nichts an der Datei.
+
+ Schritt F — GREEN: `pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts src/prisma/rls-access-inventory.spec.ts` gruen; volle Suite `Test Files 67 passed (67)` / `Tests 1076 passed (1076)`; `pnpm -C apps/api exec tsc --noEmit` Exit 0. Weicht eine Zahl ab, ist das ein Befund fuer das SUMMARY — erst die Ursache benennen, dann korrigieren.
+
+ Commit: `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` mit genau den 16 Dateien dieser Aufgabe (`git show --stat HEAD` zeigt 16).
+
+
+ cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts src/prisma/rls-access-inventory.spec.ts 2>&1 | grep -E "^\s+(Test Files|Tests)" ; DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1); (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate status 2>&1 | tail -1 ; DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script 2>/dev/null | head -1) ; ls apps/api/prisma/migrations | grep -c "" ; grep -c "bugReportRecipient String?" apps/api/prisma/schema.prisma ; grep -c "ADD COLUMN \"bugReportRecipient\" TEXT" apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql ; grep -c "FileInterceptor('screenshot'" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -v '^\s*//' apps/api/src/bug-reports/bug-reports.controller.ts | grep -v '^\s*\*' | grep -c "@Roles" ; grep -v '^\s*//' apps/api/src/bug-reports/dto/bug-report.dto.ts | grep -v '^\s*\*' | grep -c "tenantId" ; grep -c "const tenantPrisma = forTenant(" apps/api/src/bug-reports/bug-reports.service.ts ; grep -c "sendBugReport" apps/api/src/mail/mail.service.ts ; grep -c "BugReportsModule" apps/api/src/app.module.ts ; grep -c "bug-reports.service.ts | user | muss-mandantengebunden | gebunden" docs/mandantentrennung-zugriffsklassifikation.md ; grep -c 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}' docker-compose.prod.yml ; M=$(git diff --stat 5c42c55 -- apps/api/src/main.ts '.env*' apps/api/prisma/migrations/2026061* apps/api/prisma/migrations/2026062* apps/api/prisma/migrations/2026080* apps/api/prisma/migrations/2026081* apps/api/prisma/migrations/2026090* apps/api/prisma/migrations/2026091[0-4]12*); echo M_EXIT=$? ; test -z "$M"; echo M_EMPTY=$? ; pnpm -C apps/api exec tsc --noEmit; echo TSC_api=$?
+
+
+ Vitest-Zeilen `Test Files 5 passed (5)` und `Tests ` — die volle Suite danach `67 passed (67)` / `1076 passed (1076)`; `migrate status` letzte Zeile `Database schema is up to date!`, `migrate diff` erste Zeile `-- This is an empty migration.`; Migrationsordner-Zaehlung `38` (36 + `migration_lock.toml` + 1 neu); Greps liefern `1` (Schema), `1` (Migration), `1` (FileInterceptor), `0` (kein Rollen-Dekorator im Controller), `0` (kein Mandantenfeld im DTO), `1` (Zuweisungsform), mindestens `2` (sendBugReport in mail.service.ts), `2` (Import + Eintrag), `1` (Doku-Zeile), `1` (Compose); `M_EXIT=0` und `M_EMPTY=0` (main.ts, .env*, bestehende Migrationen unangetastet); `TSC_api=0`. Der RED-Lauf aus Schritt A und die drei Prisma-Ausgaben aus Schritt B stehen im SUMMARY. Commit existiert mit genau 16 Dateien.
+
+
+
+
+ Task 2: Web — html-to-image, Fehlerpuffer, Knopf in der Kopfzeile, Dialog mit Vorschau, Feld „Fehlermeldungen an" im SMTP-Formular, i18n de/en, Komponententests
+ apps/web/package.json, pnpm-lock.yaml, apps/web/src/lib/error-buffer.ts, apps/web/src/lib/error-buffer.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/components/bug-report/bug-report-button.tsx, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/lib/settings-api.ts, apps/web/src/components/settings/smtp-settings-form.tsx, apps/web/src/components/settings/smtp-settings-form.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts
+
+ `apps/web/src/lib/error-buffer.test.ts` (NEU, 4 Tests; jsdom; `afterEach`: `vi.restoreAllMocks()`, `vi.unstubAllGlobals()`, Puffer per exportiertem `clearErrorBuffer()` leeren, Installations-Guard per exportiertem `uninstallErrorBuffer()` zuruecksetzen):
+ - Test 1 (Ringpuffer): 25-mal `recordError('error', 'm')` -> `getRecentErrors().length === 20`, erster Eintrag `m5`, letzter `m24`; jeder Eintrag hat `at` (ISO), `kind`, `message`.
+ - Test 2 (fetch-Wrapper): `vi.stubGlobal('fetch', vi.fn(async (input, init) => new Response('{"statusCode":500,"message":"kaputt"}', { status: 500 })))`; `installErrorBuffer()`; `await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' })` -> genau ein Eintrag `kind: 'fetch'`, Meldung beginnt mit `POST /api/x -> 500`, enthaelt `kaputt`, enthaelt NICHT `geheim` und NICHT `password`; danach Antwort 200 -> KEIN neuer Eintrag; die Antwort selbst ist weiterhin lesbar (`await res.json()` liefert das Objekt — Wrapper liest nur einen `clone()`).
+ - Test 3 (console.error reicht durch): `const orig = vi.spyOn(console, 'error').mockImplementation(() => {})` VOR `installErrorBuffer()`; `console.error('boom', { a: 1 })` -> `orig` genau einmal gerufen, ein Eintrag `kind: 'console.error'` mit `boom` im Text.
+ - Test 4 (idempotent): `installErrorBuffer()` zweimal, dann eine fehlgeschlagene Antwort -> genau EIN Eintrag (kein doppeltes Wrapping); `formatErrorsForReport()` liefert Zeilen der Form `[] fetch: …`.
+ `apps/web/src/components/bug-report/bug-report-button.test.tsx` (NEU, 6 Tests; `vi.mock('html-to-image', () => ({ toPng: (...a) => mockToPng(...a) }))`; `vi.mock('next-intl')` mit Namensraum `bugReport` (Schluessel wie in Schritt E, Werte deutsch) und `header`; `vi.mock('@/lib/stores/auth-store', () => ({ useAuthStore: (sel?: any) => (sel ? sel({ user: mockUser }) : { user: mockUser }) }))`; `vi.mock('@/lib/app-version', () => ({ appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' } }))`; `vi.stubGlobal('fetch', mockFetch)`; `@testing-library/user-event` fuer Klicks; Komponente per dynamischem Import nach den Mocks; `cleanup` im `afterEach`):
+ - Test 1 (Bild VOR dem Dialog): `mockToPng` prueft in seiner Implementierung `expect(screen.queryByRole('dialog')).toBeNull()` und liefert `'data:image/png;base64,iVBORw0KGgo='`; Klick auf `getByRole('button', { name: 'Fehler melden' })` -> `mockToPng` genau einmal mit `document.body` als erstem Argument und Optionen `pixelRatio: 1`, `skipFonts: true`; danach `findByRole('dialog')` sichtbar, `` mit `src` = Data-URL, Checkbox `checked`, Textfeld leer.
+ - Test 2 (Senden mit Bild): Beschreibung `Knopf tut nichts` tippen, `recordError('fetch', 'GET /modules -> 500')` vorher, `mockFetch` -> `new Response('{"sent":true}', { status: 200 })`; Klick „Senden" -> `mockFetch` einmal; URL endet auf `/bug-reports`; `init.method === 'POST'`, `init.credentials === 'include'`, `init.body instanceof FormData`, `body.get('description') === 'Knopf tut nichts'`, `body.get('webVersion') === 'v1.2.3'`, `body.get('webChannel') === 'beta'`, `body.get('page')` beginnt mit `/`, `body.getAll('errors')` enthaelt einen Eintrag mit `GET /modules -> 500`, `body.get('screenshot')` ist eine `Blob` mit `type === 'image/png'` und `size > 0`; kein `Content-Type`-Header in `init.headers`; danach Text `Vielen Dank, die Meldung wurde gesendet.` und ein Knopf „Schließen".
+ - Test 3 (Haekchen aus): Checkbox abwaehlen, senden -> `body.has('screenshot') === false`.
+ - Test 4 (409 als Admin): `mockUser.role = 'ADMIN'`, `mockFetch` -> `new Response('{"statusCode":409,"message":"…"}', { status: 409 })` -> Text der `notConfigured`-Meldung UND der Admin-Hinweis mit einem Link `href="/admin/smtp"`; als `USER` (zweiter Render) KEIN Link.
+ - Test 5 (Escape schliesst): Dialog offen, `user.keyboard('{Escape}')` -> `queryByRole('dialog')` null.
+ - Test 6 (Aufnahme scheitert): `mockToPng` lehnt ab -> Dialog oeffnet trotzdem, Text `Kein Bildschirmfoto möglich`, Checkbox `disabled` und nicht `checked`, Senden -> `body.has('screenshot') === false`.
+ `apps/web/src/components/settings/smtp-settings-form.test.tsx` (NEU, 2 Tests; `vi.mock('@/lib/settings-api')` mit `fetchSmtp`, `saveSmtp`, `testSmtp` als `vi.fn`; next-intl-Mock fuer `settings` mit den `smtp.*`-Schluesseln):
+ - Test 1 (vorbelegt): `fetchSmtp` liefert `{ host: 'h', port: 587, encryption: 'starttls', fromAddress: 'a@b.invalid', hasPassword: false, bugReportRecipient: 'fehler@b.invalid' }` -> `findByLabelText('Fehlermeldungen an')` hat den Wert `fehler@b.invalid`.
+ - Test 2 (Payload): Wert auf `neu@b.invalid` aendern, Formular absenden -> `saveSmtp` mit `bugReportRecipient: 'neu@b.invalid'`; Feld leeren und erneut absenden -> `bugReportRecipient: null`.
+
+
+ Schritt A — Abhaengigkeit: in `apps/web/package.json` unter `dependencies` alphabetisch `"html-to-image": "1.11.13"` (exakt, ohne Caret — Bildaufnahme ist empfindlich gegen Verhaltensaenderungen); dann `pnpm install` (Lockfile aendert sich, erwartet), danach `pnpm install --frozen-lockfile` Exit 0 (Gate). `ls apps/web/node_modules/html-to-image/lib/index.d.ts` vorhanden. Kein anderes Paket anfassen; `git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json` bleibt leer.
+
+ Schritt B — RED: die drei Testdateien aus `` anlegen; `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx` muss rot sein — Ausgabezeilen ins SUMMARY.
+
+ Schritt C — Fehlerpuffer `apps/web/src/lib/error-buffer.ts` (Kopfkommentar deutsch ASCII: Zweck, Grenzen, Sicherheitsregel T-M97-02): `export interface BufferedError { at: string; kind: 'error' | 'unhandledrejection' | 'console.error' | 'fetch'; message: string }`; `MAX_ENTRIES = 20`, `MAX_MESSAGE = 1000`, `BODY_EXCERPT = 200`; Modul-Array als Ringpuffer; `export function recordError(kind, message)` (kuerzt auf `MAX_MESSAGE`, `shift()` bei Ueberlauf); `export function getRecentErrors(): BufferedError[]` (Kopie); `export function clearErrorBuffer()`; `export function formatErrorsForReport(): string[]` (`[${at}] ${kind}: ${message}`); `export function installErrorBuffer(): void` — bei `typeof window === 'undefined'` sofort zurueck; Guard ueber eine Eigenschaft `__tesseraErrorBufferInstalled` auf `window` (idempotent, auch bei React-StrictMode-Doppeleffekten); registriert `window.addEventListener('error', e => recordError('error', e.message + ' @ ' + e.filename + ':' + e.lineno))` und `('unhandledrejection', e => recordError('unhandledrejection', String(e.reason?.message ?? e.reason)))`; ersetzt `console.error` durch eine Funktion, die ZUERST das Original mit denselben Argumenten aufruft und dann die Argumente als Text notiert (Strings direkt, Error-Objekte ueber `.message`, sonst `JSON.stringify` mit try/catch); ersetzt `window.fetch` durch einen Wrapper, der das Original aufruft und NUR bei `!response.ok` notiert: Methode (`init?.method ?? 'GET'`, gross), Pfad ohne Suchteil (`new URL(String(typeof input === 'string' ? input : input.url), window.location.href).pathname`), Status und die ersten 200 Zeichen von `await response.clone().text()` (try/catch; nie `init.body`, nie Kopfzeilen); Netzwerkfehler (`fetch` wirft) werden als `fetch: -> Netzwerkfehler` notiert und weitergeworfen; `export function uninstallErrorBuffer()` stellt Original-`fetch`/`console.error` wieder her und loescht den Guard (nur fuer Tests; kein Aufrufer im Produktionscode).
+ In `app-shell.tsx`: Import und `useEffect(() => { installErrorBuffer(); }, []);` neben dem bestehenden `setMounted`-Effekt (Kommentar: einmal je Seitenladung, SSR-sicher).
+
+ Schritt D — `apps/web/src/lib/bug-report-api.ts`: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` (Muster `settings-api.ts`). `export async function captureScreenshot(): Promise`: `const { toPng } = await import('html-to-image')` (dynamischer Import, Bibliothek nur bei Klick geladen — Vorsicht: der Test-Mock greift auch bei dynamischem Import); `const node = document.body`; `const w = node.scrollWidth, h = node.scrollHeight`; `const scale = Math.min(1, 1600 / Math.max(w, h, 1))`; `toPng(node, { pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth: Math.round(w * scale), canvasHeight: Math.round(h * scale), filter: (n) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true') })`; Fehler -> `null` (nie werfen; Kommentar: Bild ist Beigabe, der Bericht geht auch ohne). `export function dataUrlToBlob(dataUrl: string): Blob` (Base64 nach dem Komma per `atob` in `Uint8Array`, `new Blob([bytes], { type: 'image/png' })`). `export interface BugReportPayload { description: string; page: string; webVersion: string; webChannel: string; webCommit: string; userAgent: string; viewport: string; clientTime: string; errors: string[]; screenshot: Blob | null }`. `export async function sendBugReport(p: BugReportPayload): Promise<{ ok: true } | { ok: false; status: number }>`: `FormData` mit allen Textfeldern (`errors` je Eintrag per `append('errors', …)`), `screenshot` nur wenn nicht null (`append('screenshot', blob, 'screenshot.png')`); `fetch(`${API_URL}/bug-reports`, { method: 'POST', credentials: 'include', body })` OHNE `headers` (der Browser setzt die Multipart-Grenze selbst); `res.ok` -> `{ ok: true }`, sonst `{ ok: false, status: res.status }`; `fetch`-Fehler -> `{ ok: false, status: 0 }`.
+
+ Schritt E — Uebersetzungen `de.json`/`en.json` (in BEIDEN Dateien an derselben Stelle: neuer Namensraum `bugReport` hinter `theme`; `settings.smtp` um zwei Schluessel hinter `testFailed`). Deutsch (Sie-Form, echte Umlaute): `bugReport.button` „Fehler melden"; `title` „Fehler melden"; `intro` „Tessera hat gerade ein Bild dieser Seite aufgenommen – es zeigt genau das, was Sie sehen. Bild, Beschreibung, Seite, Version, Browser und die letzten Fehlermeldungen gehen als E-Mail an Ihren Administrator."; `screenshotAlt` „Vorschau des Bildschirmfotos"; `screenshotUnavailable` „Kein Bildschirmfoto möglich – die Meldung wird ohne Bild gesendet."; `attachScreenshot` „Bildschirmfoto beifügen"; `descriptionLabel` „Was ist passiert?"; `descriptionPlaceholder` „Optional: Was haben Sie getan, was haben Sie erwartet, was ist stattdessen geschehen?"; `send` „Senden"; `sending` „Wird gesendet…"; `cancel` „Abbrechen"; `close` „Schließen"; `sent` „Vielen Dank, die Meldung wurde gesendet."; `errorNotConfigured` „Für Fehlermeldungen ist noch kein Postfach eingerichtet."; `errorNotConfiguredAdminHint` „Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an" fest."; `errorNotConfiguredAdminLink` „Zu den SMTP-Einstellungen"; `errorTooMany` „Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut."; `errorTooLarge` „Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto."; `errorSendFailed` „Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator."; `errorGeneric` „Die Meldung konnte nicht gesendet werden."; `settings.smtp.bugReportRecipient` „Fehlermeldungen an"; `settings.smtp.bugReportRecipientHelp` „Optional – Postfach, an das Anwender über den Knopf „Fehler melden" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll.". Englisch sinngemaess (`Report a problem`, `Attach screenshot`, `What happened?`, `Thank you, your report has been sent.`, `Bug reports to`, …). Danach `pnpm -C apps/web exec vitest run src/messages` — flaggt der Umlaut-Waechter korrekte Woerter (erwartet mindestens `passiert`; moeglich `aktuellen`, `geschehen` ist frei), diese GENAU SO in `UMLAUT_ALLOWLIST` in `umlaut-dictionary.ts` eintragen (mit Kommentar `// 260914-m97`); keine Ersatzschreibung (`ae/oe/ue/ss` statt Umlaut) einfuehren.
+
+ Schritt F — Komponenten (Verzeichnis `apps/web/src/components/bug-report/`):
+ 1. `bug-report-dialog.tsx` (`'use client'`; Props `open: boolean`, `screenshot: string | null`, `isAdmin: boolean`, `onClose: () => void`; `useTranslations('bugReport')`): Aufbau wie `ActivationDialog.tsx` (`fixed inset-0 z-50 flex items-center justify-center bg-black/50`, innen `w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg`, `role="dialog" aria-modal="true" aria-labelledby`), Escape -> `onClose` (nicht waehrend `sending`), Fokus beim Oeffnen auf das Textfeld; Zustand `status: 'ready' | 'sending' | 'sent' | 'failed'`, `failedStatus: number`, `description`, `attach` (Vorgabe `screenshot !== null`); Inhalt: Titel, `intro`-Absatz (`text-sm text-muted-foreground`), Bild `` oder `screenshotUnavailable`-Text, Checkbox (`id="bug-report-attach"`, `disabled={screenshot === null}`), Textarea (`id="bug-report-description"`, `rows={4}`, `maxLength={4000}`, Platzhalter), Knopfzeile „Abbrechen"/„Senden" (Stile der ActivationDialog-Knoepfe, Primaerknopf `bg-primary text-primary-foreground`), `disabled` waehrend `sending`; nach `sent`: Erfolgstext und ein Knopf „Schließen"; nach `failed`: Meldung je Status (409 -> `errorNotConfigured` + bei `isAdmin` `errorNotConfiguredAdminHint` und `next/link` auf `/admin/smtp` mit `errorNotConfiguredAdminLink`; 429 -> `errorTooMany`; 413 -> `errorTooLarge`; 502 -> `errorSendFailed`; sonst `errorGeneric`) in `text-sm text-destructive`, Knoepfe bleiben, erneutes Senden moeglich. `handleSend`: `sendBugReport({ description: description.trim(), page: window.location.pathname + window.location.search, webVersion: appVersion.version, webChannel: appVersion.channel, webCommit: appVersion.commit, userAgent: navigator.userAgent, viewport: `${window.innerWidth}x${window.innerHeight}`, clientTime: new Date().toISOString(), errors: formatErrorsForReport(), screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null })`. Das Wurzelelement traegt `data-bug-report-ignore="true"` (defensiv, falls je waehrend offenem Dialog aufgenommen wuerde).
+ 2. `bug-report-button.tsx` (`'use client'`): `useTranslations('bugReport')`, `const user = useAuthStore((s) => s.user)`, `isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'`; Zustand `capturing`, `open`, `screenshot`; `handleClick`: wenn `capturing` zurueck; `setCapturing(true)`; `const shot = await captureScreenshot()` — ERST DANACH `setScreenshot(shot); setOpen(true); setCapturing(false)` (Kommentar: Reihenfolge ist die Kernanforderung — kein Dialog im Bild); Knopf `