# 📋 OpenCRM – Todo-Liste --- ## 🔜 Offen ### Manuelle Tests (vor Release durchklicken) Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**. Einmal komplett durchlaufen vor v1.0.0-Release. ### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless **Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal. **Architektur-Entscheidung:** Weg C (Instance-per-Customer) - Pro Kunde eine eigene Docker-Instanz mit eigener DB - Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter - Komplette Datenisolation (DSGVO-freundlich) - Updates können gestaffelt ausgerollt werden (erst 10% testen) - Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket" **Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) – Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf. --- **Admin-Portal (separate App, neben den CRM-Instanzen):** - Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled) - "Neuen Kunden anlegen" → Provisioning-Script - DB anlegen (Master-DB kennt die Mapping) - Docker-Container starten - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik) - Initial-Admin-Account erstellen + Einladungs-Email senden - Optional: Factory-Defaults fĂŒr Stammdaten einspielen - GoCardless-Integration (Webhook + Dashboard) - Instanz-Management: Pause/Resume bei Zahlungsproblemen - Logs & Metriken pro Instanz (optional) - Support-Bereich (Tickets? oder einfach E-Mail) --- **Abrechnung mit GoCardless (gocardless.com):** - Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success) - 30 Tage kostenlose Testphase ohne Zahlungsmittel - Nach Trial: Mandats-Erfassung → regelmĂ€ĂŸige Abbuchung - Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features - Webhook-Endpoint im Admin-Portal: - `payment_confirmed` → Instanz aktiv lassen - `payment_failed` → Banner im CRM, nach X Tagen pausieren - `mandate_cancelled` → KĂŒndigungs-Flow - Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen** (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen) --- **Provisioning-Flow (grober Entwurf):** 1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain) 2. Admin-Portal: Trial-Instanz starten - DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain - Einladungs-Email mit Admin-Login + Passwort-Reset-Link 3. Tag 25: Erinnerungs-Email "Deine Trial lĂ€uft bald ab" 4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren" 5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login 6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv 7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis) --- **Technische Bausteine fĂŒr spĂ€ter:** - Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs) - Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt) - Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s - Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups - Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen - Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen --- **Grobe ZeitschĂ€tzung:** - Admin-Portal (MVP): ~1 Woche - GoCardless-Integration + Webhooks: ~3-5 Tage - Provisioning-Automatisierung (Docker + Caddy): ~1 Woche - Landing Page + Checkout: ~3-5 Tage - Tests + Polishing: ~1 Woche - **Gesamt: ~3-4 Wochen** **Vorbereitung JETZT (einfach, macht spĂ€ter Arbeit leichter):** - ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning) - ✅ Domain/Label dynamisch per Provider (schon erledigt) - Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag) - Backup-Script robust + wiederherstellbar (haben wir schon weitgehend) --- ## ✅ Erledigt - [x] **đŸ€ Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)** - Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal), **ohne** Consent-Pflicht nutzbar. Zwei Abschnitte: 1. „ wurde an Board geholt durch:" – **max. 1** Werber (DB-Unique auf `recruitedId`). 2. „ hat folgende Kunden an Board geholt:" – beliebig viele. - Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche ĂŒber Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in, Kind, Freund/Kumpel, 
 „Bitte auswĂ€hlen!"). Löschen + Externtab-Link zur Kundenakte pro Zeile. - **Bidirektional aus einem Datensatz:** trĂ€gt man in Kunde A „geworben durch B" ein, erscheint A automatisch bei B unter „hat geworben". Von beiden Akten hinzufĂŒgbar/löschbar. - Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId` @unique, `relationship`) + Migration. Beziehungs-Whitelist server- seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE auditiert. - [x] **🔄 „Neue Version verfĂŒgbar"-Banner (offener-Tab-Problem)** - Cache-Header waren schon optimal (index.html `no-store`, Assets `immutable`) – aber eine laufende SPA holt sich nach einem Deploy nicht von selbst neuen Code. Neuer `useVersionCheck`-Hook holt die (immer frische) index.html periodisch + beim ZurĂŒckkehren zum Tab, vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei Änderung erscheint ein dezentes Banner „Neue Version verfĂŒgbar → Jetzt neu laden" (`UpdateBanner`, oben im Layout). - Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op (keine `/assets/`-Dateien → leere Signatur). - [x] **👁 Kundenportal: Toggle „Deaktivierte VertrĂ€ge anzeigen"** - Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon; jetzt auch in der Kundenportal-VertragsĂŒbersicht (`ContractList`, Portal-Zweig). Button nur bei `isCustomerPortal`, reicht `includeDeactivated` an die `getTreeForCustomer`-Queries durch (`showDeactivated` im Query-Key → frischer Fetch beim Umschalten). - Security: nutzt denselben Endpoint hinter `canAccessCustomer` (R120) – Portal-Kunde sieht nur eigene/vertretene BĂ€ume, das Flag weitet nur den Status-Filter innerhalb der erlaubten Daten. Keine neue Exposition. - [x] **🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)** - `mobileNetwork` akzeptierte serverseitig jeden (stripHtml-bereinigten) String – das Frontend-Dropdown beschrĂ€nkte nur clientseitig. Jetzt Whitelist `TELEKOM|VODAFONE|TELEFONICA` in `contract.service.ts` (`normalizeMobileNetwork`), angewandt in Create- UND Update-Pfad (Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/ leere Werte → null. - [x] **🔒 Rate-Limiting: IPv6-Bypass-HĂ€rtung (ipKeyGenerator)** - Die Rate-Limiter mit eigenem `keyGenerator` (`loginRateLimiter`, `staffPasswordReAuthLimiter`) keyten auf die **volle** `req.ip`. Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block (/56–/64) pro Versuch eine neue Adresse nehmen und so das Per-IP-Limit (Login-Bruteforce, Passwort-Set-Reauth) umgehen. - Fix: `req.ip` in beiden `keyGenerator` durch `ipKeyGenerator(...)` (express-rate-limit v7) ersetzt → IPv6 wird auf das Subnetz normalisiert (Library-Default /56), IPv4 unverĂ€ndert. Verifiziert: zwei verschiedene IPv6 im selben /56 ⇒ derselbe Key. - Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, Consent) machten das schon ĂŒber den Library-Default – jetzt konsistent. (Ersetzt den verworfenen aria-WIP; auf aktuellem Stand neu gebaut.) - [x] **🐞 Spam-Tab: AnhĂ€nge aus Junk-Ordner (Pentest R124-Fund)** - Beim Spam-Feature wurden `moveEmailToTrash`/`restoreEmailFromTrash` auf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen im Controller (`downloadAttachment`, `saveAttachmentTo`, `saveAttachmentAsInvoice`, `saveAttachmentAsContractDocument`) bestimmten den IMAP-Ordner weiter hart als `email.folder === 'SENT' ? 'Sent' : 'INBOX'`. FĂŒr SPAM-Mails landete das fĂ€lschlich auf INBOX. Da IMAP-UIDs pro Ordner vergeben sind: 404 (Anhang nicht gefunden) oder – bei UID-Kollision – der falsche Anhang aus INBOX (z.B. als Rechnung/Vertragsdokument abgelegt). Kein Cross-Customer-Leak (bleibt im selben Postfach), aber echtes DatenintegritĂ€tsproblem. - Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in `moveEmailToTrash` + `findJunkFolderPath` importiert. - Randfall (Pentester-Nachtrag): findet sich der Junk-Ordner nicht (mehr), wird jetzt ein klarer Fehler zurĂŒckgegeben statt still auf INBOX/`'Junk'` zu raten – in allen 4 Controller-Funktionen (404 „Anhang nicht abrufbar") und den 2 Service-Funktionen (`moveEmailToTrash`/`restoreEmailFromTrash`). Kein `undefined` mehr Richtung IMAP-Lib. - [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab** - Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client, zeigt den Junk-/Spam-Ordner des gewĂ€hlten Postfachs – damit fĂ€lschlich als Spam eingestufte Mails auffindbar sind. - Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration `20260727110000_email_folder_spam`, Enum-Wert angehĂ€ngt). IMAP-Sync ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` + Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct ihn zusĂ€tzlich als `dbFolder=SPAM`. `getCachedEmails` + `getFolderCountsForAccount` um SPAM erweitert. - Papierkorb-Verschieben/Wiederherstellen fĂŒr Spam-Mails nutzt den tatsĂ€chlichen Junk-Pfad als Quell-/Zielordner. - Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich (Rettung legitimer Mails). - [x] **đŸ“± Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif** - Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswĂ€hlen (leer), Telekom, Vodafone, TelefĂłnica. - Neues Feld `MobileContractDetails.mobileNetwork` (String, nullable, speichert TELEKOM/VODAFONE/TELEFONICA) + Migration `20260727100000_mobile_network` (`ADD COLUMN IF NOT EXISTS`). String statt Enum, damit weitere Netze ohne Migration möglich sind. - Anzeige in der Vertragsansicht (Mobilfunk-Details) mit lesbarem Netz-Namen. - [x] **🐞 LieferbestĂ€tigung: eingegebenes Datum Ă€ndert Vertragsbeginn nicht** - Beim Upload einer LieferbestĂ€tigung mit Datum wurde `startDate` nur gesetzt, wenn es noch LEER war (`if (!contract.startDate)`). Hatte der Vertrag schon ein (geschĂ€tztes) Beginndatum, blieb es trotz eingetragenem Lieferdatum stehen. - Fix in `maybeActivateOnDeliveryConfirmation`: ein explizit eingegebenes Lieferdatum ĂŒberschreibt den Vertragsbeginn jetzt IMMER (die LieferbestĂ€tigung ist das maßgebliche tatsĂ€chliche Startdatum). Der Fallback „heute" (kein Datum eingegeben) fĂŒllt weiterhin nur ein leeres Feld, um ein echtes Datum nicht versehentlich zu ĂŒberschreiben. No-op + Audit-Log unverĂ€ndert. Frontend schickte das Datum bereits mit und lĂ€dt den Vertrag nach Upload neu – kein FE-Change nötig. - [x] **🎂 Anstehende Geburtstage auch im Dashboard** - Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit. Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin, `enabled: !isCustomer`), gleiche `birthdayApi.getUpcoming(7, 30)`- Query + identische Karten-Darstellung (Heute/vergangen/kommend, Link auf die Kundenakte). Kein Backend-Change nötig. - [x] **🔮 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId** - `POST /api/audit-logs/verify` meldete ~73% der EintrĂ€ge als manipuliert. Kein echtes Tampering, sondern ein Bug in `generateHash`: `resourceId` war beim Schreiben oft `undefined` (middleware-EintrĂ€ge ohne Route-ID). `JSON.stringify` LÄSST einen undefined-Wert weg → Hash ohne `resourceId`-Key. In der DB landet der Wert aber als `NULL`; `verifyIntegrity`/`rehashAll` lasen ihn als `null` zurĂŒck und `JSON.stringify({resourceId:null})` schrieb ihn REIN → anderer Hash → Fehlalarm fĂŒr JEDE leere-resourceId-Zeile. Die vom Pentester gefundene `durationMs`-Korrelation war ein Proxy: middleware-EintrĂ€ge (durationMs gesetzt) haben oft kein resourceId. - Fix: `generateHash` lĂ€sst nullish `resourceId` weg – reproduziert exakt das historische Schreibverhalten. Kein Caller ĂŒbergibt je `null` explizit (verifiziert) → alle Bestands-Hashes matchen ohne Rehash. Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära (id>4141) 482/482 valide (vorher alle 289 null-Zeilen geflaggt). - Separat/vorbestehend: EintrĂ€ge VOR Commit `fd55742` („complete new audit system") nutzen ein altes Hash-Schema und wurden nie neu baselined → scheitern unabhĂ€ngig davon. Remediation: einmalig `POST /api/audit-logs/rehash` (Admin). Nicht Teil dieses Fixes. - [x] **🔧 Pentest R120 – Audit-Log `/:id` mit nicht-numerischer ID → 500 statt 400** - Pentester stiess beim Suchen eines `verify-integrity`-Endpoints auf einen 500er. Ursache: `GET /api/audit-logs/verify` matcht `GET /:id` (das echte Verify ist `POST /verify`), `parseInt("verify")` = NaN → Prisma `findUnique({ where:{ id: NaN }})` wirft → 500. Gleiche „400 statt 500"-Klasse wie R64.1/R104.1. - Fix: `Number.isNaN`-Guard in `getAuditLogById`, `getAuditLogsByCustomer` und `updateRetentionPolicy` → sauberer 400. - [x] **🔮 Pentest R120 – CRITICAL IDOR: Vertragsbaum fremder Kunden lesbar** - `GET /api/contracts?tree=true&customerId=` returnte fĂŒr Portal-User den vollstĂ€ndigen Vertragsbaum eines beliebigen Fremdkunden (Name, Kundennummer, Vertragsnummern, Tarife). Der `tree=true`-Zweig im Controller returnte **frĂŒh**, bevor die Portal-User-`customerIds`-Filterung unten fĂŒr die flache Liste griff. Vorbestehender Bug – der Toggle „Deaktivierte anzeigen" (siehe unten) hat ihn nur sichtbarer gemacht, weil selbst archivierte FremdvertrĂ€ge mit auftauchten. - Fix: `canAccessCustomer(req, res, customerId)` vor dem frĂŒhen Return ([contract.controller.ts:80](../backend/src/controllers/contract.controller.ts#L80)). PrĂŒft eigene Customer-ID + vertretene MIT Live-Vollmacht, sendet selbst die 403. Staff (Nicht-Portal) passiert unverĂ€ndert. Gleiches Muster wie Pentest 56.3 bei update/delete. - Zusatzbefund (Pentester): liegen gebliebenes `` → `stripHtml` → leerer String → Vertragsnummer silent ĂŒberschrieben. - Fix: `contractNumber` zu `CONTRACT_IDENTIFIER_FIELDS` hinzugefĂŒgt – bestehende R86-Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) greift automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin. Legitime `GAS-MPVJ4P10FE8` bleibt OK, alle Angriffs-Payloads → 400. - Doku in `SECURITY-HARDENING.md § Runde 102`. - [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log** - Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline` bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im `fileDownload.controller` ist eigentlich korrekt – sauberer Magic- Byte-Check fĂŒr PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod aber nicht reproduzieren. - Refaktorierung: Magic-Byte-Check in `detectSafeContentType()` extrahiert, finally-Block schließt File-Descriptor garantiert, Short-Read-FĂ€lle (`bytesRead < n`) jetzt sauber geguardet. - Sicherheits-Verhalten unverĂ€ndert: bei Magic-Byte-Mismatch bleibt es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus R30.13). - Neu: `console.warn`, wenn `inline` angefragt wurde, aber der Magic-Byte-Check fehlschlĂ€gt oder der Read crasht. Damit fĂ€llt der Fall im Prod-Log auf, falls er nochmal auftritt – bisher war's silent. - [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler** - R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`) erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang durchgehen. - R97.2 (INFO): keine App-Level-Caps (GrĂ¶ĂŸe + Anzahl) – die im Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am bodyParser; falls der je hochgedreht wird, fĂ€llt die Sicherung. - Fix: `validateAttachments()` im Controller `sendEmailFromAccount` lĂ€uft **vor** dem `sendEmail`-Aufruf: - `attachments` muss Array oder undefined sein - max 25 AnhĂ€nge - jeder: `filename` non-empty String, `content` non-empty Base64- String (Regex), optional `contentType` String - max 10 MB/Datei, 25 MB gesamt (SchĂ€tzung via base64-LĂ€nge × 0.75, kein Buffer.from-Aufruf wĂ€hrend der Validierung) - Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases grĂŒn inkl. aller R97.1-Pentest-Payloads. - [x] **🆕 E-Mail-Compose: Vertragsdokumente anhĂ€ngen + Kundendaten einfĂŒgen** - Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben "Datei anhĂ€ngen": - **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente gruppiert nach `documentType`. Auswahl → Server-Download via `fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 → direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit. - **Kundendaten einfĂŒgen**: zeigt nur Sections die tatsĂ€chlich Daten haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank + Ausweis zusĂ€tzlich Sub-Checkbox "als PDF anhĂ€ngen", wenn ein `documentPath` vorhanden ist. - Beim BestĂ€tigen werden die Text-Blöcke an das Body-Ende gehĂ€ngt (mit `\n\n`-Separator), AnhĂ€nge per `serverFileToAttachment` aus `composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt) wird beidseitig geprĂŒft, drĂŒberlaufende Dateien werden mit Toast ĂŒbersprungen statt silent weggeschluckt. - Helpers (`composeAttachmentHelpers.ts`): - `serverFileToAttachment(path, filename)` – fetch via Token-URL → Blob → base64 → `EmailAttachment`. - `totalAttachmentBytes` – GrĂ¶ĂŸen-Check unter BerĂŒcksichtigung der ~33 % base64-Overhead. - `bankCardAttachmentName` / `identityDocAttachmentName` – sprechende Dateinamen fĂŒr den EmpfĂ€nger. - [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) hĂ€rten** - R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor sobald der Wert in Mail-Templates / PDF-Footer landet. - R95.3 (LOW): `@x.de` → silent stripHtml-Mutation (R87.1-Pattern, dritter Treffer auf demselben Bug). - R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400. - Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt validiert (R87-Pattern). EingehĂ€ngt in `sanitizeContractBody`. - Frontend: `maxLength={100}` am Input. - **R95.2 bewusst nicht ĂŒbernommen** (Email-Format-Pflicht): das Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`. - [x] **🔒 Pentest R93 – Leerer String != fehlender Param** - R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten. Pentester-Spec: leerer String ist KEINE gĂŒltige Zahl. - Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik (`v === undefined` ist absent; `''`, `' '`, alles andere muss parsen). Required + optional Modes unverĂ€ndert. - Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als by-design akzeptiert (Pentester-BestĂ€tigung, kein Security-Impact). - [x] **🔒 Pentest R92 – Strict-400 fĂŒr accountId auf Vertrags-Endpunkten** - R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc` auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox- Isolation brach (alle PostfĂ€cher sichtbar). Pentester R92: per Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400. - Fix: `parsePositiveIntQuery(v, label, res, { required? })` ersetzt den alten silent-Helper. Modes: - default (optional): fehlend/leer → `undefined` (kein Filter), invalid → 400 - `{ required: true }`: fehlend/leer **oder** invalid → 400 - Verteilung: - Contract-Emails, Contract-Folder-Counts: `{ required: true }` - Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox- View ist legitim), invalid → 400 - Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf den Vertrags-Queries – kein UX-Bruch. - [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param** - R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit ungĂŒltigem `accountId` sah alle Mailbox-Mails fĂŒr seinen Vertrag statt nur die aus dem gewĂ€hlten Postfach (kein Cross-Customer- Leak — `canAccessContract` greift weiter). - Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`, der nur positive Ganzzahlen aus dem Query-String akzeptiert und alles andere zu `undefined` macht. Eingesetzt in allen 5 Endpunkten, die `accountId`/`contractId` aus Query nehmen (Contract-Emails, Contract-Folder-Counts, Customer-Emails, Trash, Trash-Count) – auch da, wo der Pentester nicht getestet hat, weil derselbe Pattern ĂŒberall stand. - [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht** - Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN PostfĂ€chern des Kunden angezeigt, egal welches Postfach im Selector aktiv war. Im Vertrag fehlte zusĂ€tzlich der Vertrags-Filter fĂŒr den Papierkorb. - Backend: - `getEmailsForContract` controller akzeptiert jetzt `accountId`- Query-Param und reicht ihn als `stressfreiEmailId` an `getCachedEmails` weiter (der hat den Filter eh schon implementiert, nur niemand hat ihn aufgerufen). - `getTrashEmails` (controller + service) akzeptiert `accountId` und `contractId` als optionale Filter. Default-Verhalten unverĂ€ndert, wenn keiner gesetzt ist. - `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`, bekommt zusĂ€tzlich `trash` + `trashUnread` ins Result – sonst lĂ€ge der Trash-Badge im Vertrag wieder account-global, wĂ€hrend die Liste contract-scoped ist. - Frontend: - `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts` nehmen den Filter entgegen. - `ContractEmailsSection` reicht `selectedAccountId` in alle drei Queries durch und nimmt es in den queryKey mit auf – sonst greift der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge kommt jetzt aus den contract-scoped Counts, damit Badge und Liste synchron laufen. - `EmailClientTab` reicht `selectedAccountId` in die Trash-Query durch (Inbox/Sent waren schon korrekt). - [x] **🔒 Pentest R89 – Provider-Adressfelder hĂ€rten** - R89.1 (MEDIUM): `sanitizeNotes(
, 500)` macht silent `slice(0, 500)` statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit 200 OK gespeichert. - R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `` reduzierte auf leeren String → `null` in der DB → vorheriger Wert silent ĂŒberschrieben (R87.1-Pattern auf Adress-Feldern). - Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input, max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control- Chars außer `\n`. CRLF → LF normalisiert vor Length-Check. EingehĂ€ngt in `stripProviderStrings`. - R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester bestĂ€tigt "kein unmittelbares Risiko", React escaped korrekt, sind legitime Bestandteile mehrzeiliger Postadressen. - Doku in `SECURITY-HARDENING.md § Runde 89`. - [x] **🆕 Anbieter: Kontakt + KĂŒndigung als Stammdaten** - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`, `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`, `cancellationFax`, `cancellationAddress`. Postadressen als `TEXT` (mehrzeilig), Rest `VARCHAR(191)`. Migration `20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`. - Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt & KĂŒndigung** unterhalb der Auto-Login-Felder, getrennt in zwei Untergruppen (Kontakt / KĂŒndigung) mit kleinen Headern. Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen als `