# 📋 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] **đŸš« Gutschrift nur mit EmpfĂ€ngeradresse (Rechnung > Liefer)** (2026-08-12) - Beim Klick auf „Gutschrift anlegen" wird geprĂŒft, ob der Vertrag eine EmpfĂ€ngeradresse hat: **Rechnungsadresse hat Vorrang, sonst Lieferadresse**. Ist **keine** von beiden hinterlegt → **Modal-OK-Meldung** statt Formular. - Backend Defense-in-Depth: `createCreditNote` wirft **400**, wenn weder `billingAddressId` noch `addressId` gesetzt ist. `getCreditNoteDefaults` liefert `hasRecipientAddress` fĂŒrs Frontend. Verifiziert. - [x] **đŸ§Ÿ Gutschrift: Beleg-Upload auch fĂŒr Sachwerte + kein Unterschriftsblock bei Geld** (2026-08-12) - **Beleg-Upload jetzt fĂŒr beide Arten:** bei Geld die ÜberweisungsbestĂ€tigung, bei Sachwert das **unterschriebene Dokument**. `ReceiptControls` in der Liste fĂŒr Geld UND Sachwert (Label „Überweisungsbeleg" bzw. „Unterschr. Dokument"). Endpoint `/upload/credit-notes/:id/receipt` war schon typ-agnostisch. - **PDF-Unterschriftsblock nur noch bei Sachwert:** eine Geld-Überweisung wird nicht unterschrieben (Beleg = hochgeladene ÜberweisungsbestĂ€tigung). Bei Geld entfĂ€llt der Unterschrift/Ort-Block im PDF; im Formular sind „Ort" + „Unterschrift am" bei Geld ausgeblendet. Verifiziert (beide PDFs 1 Seite). - [x] **📩 Lieferschein-Nummernkreis fĂŒr betragslose Sachwerte (GoBD)** (2026-08-12) - Pentester-Hinweis R142: Beim Übergang Geld→betragsloser Sachwert wurde die schon vergebene Gutschriftsnummer auf null gesetzt → **LĂŒcke** in der GS-Serie. - Lösung (User): betragsloser Sachwert = **Lieferschein** mit **eigener Lieferscheinnummer** aus **separatem Nummernkreis** (`DeliveryNoteNumberRange`, Default-PrĂ€fix „LS-"). `CreditNote.deliveryNoteNumber` (nullbar, unique) + Migration. - **Gap-frei:** Nummern werden lazy pro Serie vergeben und **nie wieder freigegeben**. ÜbergĂ€nge (Geld↔betragsloser Sachwert) behalten die jeweils schon vergebene Nummer der anderen Serie reserviert am Beleg → kein Doppelverbrauch, keine LĂŒcke. Angezeigt/erzeugt wird via `effectiveNumber` je nach aktuellem Typ die passende (LS bzw. GS). - Settings-Seite verwaltet jetzt **beide** Nummernkreise (Gutschrift + Lieferschein); neue Endpunkte `GET/PUT /credit-notes/delivery-note-number- range`. PDF-Titel „Sachwert-Übergabe", Dateiname `lieferschein-
`. - Verifiziert: Sachwert 0 → LS-Nr, GS-ZĂ€hler unberĂŒhrt; Geld → GS-Nr; ÜbergĂ€nge behalten Nummern (kein Neuverbrauch). - [x] **🔐 Portal-Passwort: Reveal/Send prĂŒft Konsistenz (kein „falsches" PW)** (2026-08-11) - Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und verschlĂŒsseltes Reveal-Feld out-of-sync → Support hĂ€tte ein Passwort vorgelesen, das beim Login scheitert. - **Analyse:** Alle aktuellen Schreibpfade sind konsistent (beide Felder zusammen ODER encrypted=null ODER Rehash desselben Passworts) – der aktuelle Code erzeugt keinen Desync. Ursache war Altlast/manueller DB-Eingriff. - **Fix (Belt-and-Suspenders):** `getCustomerPortalPassword` liefert jetzt `{status: ok|none|desync}` und prĂŒft den entschlĂŒsselten Klartext per `bcrypt.compare` gegen den Login-Hash. Bei `desync` (oder EntschlĂŒsselungs- fehler) geben **weder Reveal noch Send-Credentials** das Passwort aus, sondern 409 „Dateninkonsistenz – bitte neu setzen". Reveal-Read wird mit Status auditiert. - **Diagnose-Script** `scripts/check-portal-password-sync.ts`: scannt alle Portal-Kunden auf Hash-vs-Encrypted-Desync (nur Diagnose) – fĂŒr Prod, da der Pentester kein FS-Zugriff hat. Verifiziert (desync/ok/none-FĂ€lle). - [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09) - Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0): dann findet **keine Rechnungsstellung** statt – der Kunde hat den Gegenstand einfach als Subvention erhalten. BetrĂ€ge bleiben 0, keine USt. - Backend: `amount` leer → 0; Sachwert erlaubt 0, **Geld verlangt Betrag > 0** (400 sonst). `vatRelevant` wird bei 0 erzwungen-false. - PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und **kein ZUGFeRD- Embedding** (es ist keine Rechnung). Mit Betrag → unverĂ€ndert ZUGFeRD. - Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)". - Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 → ZUGFeRD). - **Keine Gutschriftsnummer fĂŒr betragslose Sachwerte:** `number` ist jetzt nullbar (Migration `MODIFY 
 NULL`, UNIQUE bleibt); ein betragsloser Sachwert (Lieferschein) bekommt **keine** Nummer und verbraucht den Nummernkreis-ZĂ€hler **nicht**. Update-ÜbergĂ€nge: wird betragslos → Nummer entfernt; bekommt nachtrĂ€glich einen Betrag → jetzt Nummer vergeben. PDF/ Liste/Audit zeigen dann „Sachwert-Übergabe"/„Beleg #id". Verifiziert (ZĂ€hler bleibt bei Sachwert 0 stehen, springt nur bei echten Gutschriften). - [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07) - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit `previousContractId` an – der Verlust passierte im Frontend: das „VorgĂ€nger-Vertrag"-Dropdown holte die VertrĂ€ge ĂŒber `getAll`, das **DEACTIVATED standardmĂ€ĂŸig ausblendet**. Beim Bearbeiten des Folgevertrags war der (deaktivierte) VorgĂ€nger daher nicht als Option vorhanden → beim Speichern ging die VerknĂŒpfung verloren. Fix: `getAllContracts` + Controller + `contractApi.getAll` um `includeDeactivated` erweitert; das VorgĂ€nger-Dropdown nutzt es (`includeDeactivated: true`) und markiert deaktivierte VertrĂ€ge mit „· deaktiviert". Verifiziert. - **Kundendaten-Modal (Schnellansicht):** zeigt jetzt zusĂ€tzlich die **Lieferadresse** und (falls abweichend) **Rechnungsadresse** des geöffneten Vertrags, die **Stressfrei-Adresse des Vertrags** (einzeln) und darunter **alle weiteren Stressfrei-Adressen** des Kunden. `CustomerInfoModal` nimmt optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId), Aufrufe aus ContractDetail + ContractForm ĂŒbergeben den Vertragskontext. - [x] **đŸš« Auto-KĂŒndigung + Cockpit-Filter KĂŒndigungsbestĂ€tigung** (2026-08-07) - **1) Auto-Status bei KĂŒndigungsbestĂ€tigung:** Wird zu einem Vertrag eine KĂŒndigungsbestĂ€tigung hinzugefĂŒgt – als **Datum** (`cancellationConfirmationDate` ĂŒbers Formular) und/oder als **Dokument** (Upload) – wird der Vertrag von ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = KĂŒndigungsdatum**. Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei neu/geĂ€ndertem BestĂ€tigungsdatum, damit manuelle Status-Korrekturen nicht zurĂŒckgesetzt werden). Verifiziert. - **2) Cockpit-Filter „KĂŒndigungsbestĂ€tigung":** listet alle VertrĂ€ge mit hinterlegter KĂŒndigungsbestĂ€tigung (Dokument und/oder Datum) in Status ACTIVE/DRAFT/CANCELLED/EXPIRED (Abgelaufen nachtrĂ€glich ergĂ€nzt). Eigene Liste `cancellationConfirmations` im Cockpit-Result (weil bereits CANCELLED-VertrĂ€ge mangels „Issue" sonst nicht auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert. - **Nachbesserungen Pentest R138:** - `endDate` wird bei **DRAFT** nicht mehr gesetzt (Entwurf = Vorlage); nur ACTIVE/CANCELLED bekommen Vertragsende = KĂŒndigungsdatum. - `cancellationConfirmationDate`/`-OptionsDate` im Update-Pfad ĂŒber `validateOptionalIsoDate` normalisiert → akzeptiert Datum-only UND volles ISO (vorher lehnte Prisma Datum-only ab), konsistent zum Upload. - **Gutschrift-Dateien:** Überweisungsbelege (`credit-note-receipts`) sind jetzt **staff-only** (neuer FileOwner-Typ `contract-staff`, Portal 403); das Gutschrift-**PDF** (`credit-notes`) bleibt vertragsbasiert (eigener Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload fĂŒr Portal war bereits per `blockPortal`/Portal-403 gesperrt. - **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr. - **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts` konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`), ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile` (creditNote-, upload-, customer-Service). Reihenfolge umgestellt: erst DB-Delete/-Update, DANN Datei löschen (schlĂ€gt DB fehl, bleibt die Datei). Verifiziert. - [~] **đŸ§Ÿ Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06) - Use-Case: zu einem Vertrag kann eine Subvention gewĂ€hrt werden – **Geld** (Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro 
, Textfeld). Betrag, Datum (Ă€nderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten", bei Geld Überweisungsbeleg (Upload, Phase 2). - **USt pro Gutschrift wĂ€hlbar** (`vatRelevant` + Basis NETTO/BRUTTO + `vatRate`) → Netto/USt/Brutto werden berechnet & getrennt gespeichert (ZUGFeRD-tauglich). `PRIVAT`/`FIRMA` aus Kunde vorbelegt. Verifiziert: nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200. - **Nummernkreis** (`CreditNoteNumberRange`, Einzel-Zeile, in Settings verwaltbar): PrĂ€fix/Jahr/Separator/Padding/StartzĂ€hler/Jahres-Reset. Nummernvergabe transaktional mit `SELECT 
 FOR UPDATE` (keine Doppel- vergabe). Beispiel `GS-2026-0001`. - Neu: Model `CreditNote` + `CreditNoteNumberRange` + Enums + Migration (IF NOT EXISTS, auf Dev angewandt). Service/Controller/Routes: `GET/POST /contracts/:id/credit-notes`, `GET .../defaults`, `GET/PUT/DELETE /credit-notes/:id`, `GET/PUT /credit-notes/number-range`. Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE auditiert. - **Phase 2a (erledigt):** Kunden-Feld `vatExempt` (Kleinunternehmer §19, Checkbox nur fĂŒr Firmenkunden) + Migration. Gutschrift-Default `vatRelevant` wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst aus. Jede Gutschrift speichert ihren Snapshot → spĂ€terer Statuswechsel Ă€ndert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten". - **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein echter Tab-Balken; ggf. spĂ€ter umbaubar): Liste + Anlegen/Bearbeiten (Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort, Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, ĂŒber `/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergĂ€nzt). Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number- range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`. - **Phase 3a (erledigt):** `CompanyProfile` (Firmenstammdaten/Absender) + Migration + Settings-Seite `/settings/company-profile`. - **Phase 3b Teil 1 (erledigt):** Gutschrift-**PDF** (pdfkit) mit Absender aus CompanyProfile, EmpfĂ€nger, Netto/USt/Brutto, Sachwert/„Ware erhalten" bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint `POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen). PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal verifiziert (valides PDF). - **Auszahlungskonto (nur GELD):** `CreditNote.payoutBankCardId` + Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung auf ein anderes Konto gehen kann. Server prĂŒft, dass die Bankkarte dem Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere Bankverbindung" + „an Bankkonto: ". Section-Zeile zeigt das Auszahlungskonto. - **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts` erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**; Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts` bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB- OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B + Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts** (im Repo unter `backend/assets/fonts`, Pflicht fĂŒr PDF/A). Dockerfile kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert (1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet, XML wohlgeformt, TypeCode 381, GrandTotal korrekt). - **⚠ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator** prĂŒfen (am besten auf Staging mit echten Firmendaten). Feinheiten (Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf. nach dem ersten Validator-Lauf nachziehen. - **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klĂ€ren – Modell/XML decken beide ĂŒber `vatRelevant` ab. ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen. - [x] **📄➕ Vertrag kopieren (neuer eigenstĂ€ndiger Vertrag aus Vorlage)** (2026-08-03) - „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das Neu-Formular unter `/contracts/new?copyFrom=&customerId=` mit ALLEN Daten des Quellvertrags vorbefĂŒllt. Beim Speichern entsteht ein frischer, unabhĂ€ngiger Vertrag (kein `previousContractId`-Link, kein VVL). - Use-Case: neuer Mobilfunk-/o.Ă€. Vertrag ist meist fast identisch – nur Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif Ă€ndern sich. - Geleert im Kopier-Modus (IntegritĂ€t / Eindeutigkeit): Status→DRAFT, VorgĂ€nger-Link, alle Datumsfelder (Start/Ende/Installation/KĂŒndigungs- bestĂ€tigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern. **Nicht** ĂŒbernommen: SIM-Karten & Rufnummern (hardware-/vertrags- spezifisch, eindeutige Nummern) – inkl. der Legacy-Scalar-Felder `phoneNumber`/`simCardNumber` (Pentest R135/136: die wurden zunĂ€chst noch kopiert → zwei getrennte VertrĂ€ge hĂ€tten dieselbe Rufnummer tragen können; jetzt im Leer-Block). **Behalten** wird der Portal-Zugang (Benutzername + Stressfrei-VerknĂŒpfung/„nicht benötigt") – bei gleichem Anbieter oft identisch; nur das verschlĂŒsselte Passwort wird nicht ins Feld geladen (wie beim Bearbeiten). Rest (Anbieter/Tarif, Preise, alle Detailfelder, Notizen) bleibt als Vorlage. - Rein Frontend (`ContractForm` copy-Mode + Button in `ContractDetail`); kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner „Kopie 
 neuer eigenstĂ€ndiger Vertrag" im Formular. - [~] **đŸ–±ïž PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30) - Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL` mit Access-Token → 60s-Download-Token → Blob/`items.add(File)`). - **Ergebnis: harte Plattformgrenze.** Ein Browser kann einer fremden Desktop-App keine echte lokale Datei zum AnhĂ€ngen ĂŒbergeben. URL-Drag → Thunderbird speichert nur einen Link (0 Bytes, lĂ€dt beim Senden nach → Fehler). Datei-Drop in den Nachrichtentext → nur Dateiname als Text. Auf die Anhang-Leiste gezogen (Thunderbird/Linux/Chromium) → ebenfalls kein echter Anhang. Webmail im Browser → gar keine Datei möglich. - **Entscheidung (User):** komplett wieder entfernt. Download- + Anzeigen- Button decken den Bedarf zuverlĂ€ssig ab. Komponente `PdfDragButton` gelöscht, `fileUrl()`-Token-Param zurĂŒckgebaut. Pentest R131 damit gegenstandslos (kein Drag-Feature mehr). - **Bleibt erhalten:** die separaten Copy-Buttons fĂŒr IBAN (Bankkarte) und Ausweisnummer im Vertragsformular (s.u.). - [x] **📋 Vertragsformular: Copy-Buttons fĂŒr Bankkarte (IBAN) + Ausweis** (2026-07-30) - In „Kundendaten verknĂŒpfen" je ein Kopieren-Button neben dem Label, kopiert nur den reinen Wert der aktuell gewĂ€hlten Option: bei Bankkarte die **IBAN ohne Namen**, bei Ausweis die **Ausweisnummer ohne (TYP)**. - Button erscheint nur, wenn eine Karte/ein Ausweis ausgewĂ€hlt ist (`watch('bankCardId')`/`watch('identityDocumentId')` → Lookup). - [x] **📧 Stressfrei-Mailbox: Passwort beim Anlegen verbindlich setzen** (2026-07-30) - Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)" wird jetzt immer ein frisch generiertes Passwort **explizit am Provider gesetzt** (`updateMailboxPassword` direkt nach dem Provisioning). - Behebt den Fall, dass die Adresse beim Provider bereits als reine Weiterleitung existierte: dann kehrte `provisionEmailWithMailbox` frĂŒh mit success zurĂŒck, ohne je ein Postfach-Passwort zu setzen – im CRM lag ein verschlĂŒsseltes Passwort, das der Provider nicht kannte → IMAP/SMTP- Login schlug fehl. Jetzt stimmen CRM und Provider garantiert ĂŒberein. - **Nachtrag Pentest R130:** Erster Versuch (`updateMailboxPassword`) setzte bei Plesk nur `-passwd`, ließ die Adresse aber auf `-mailbox false` → Mailbox nie aktiviert, Login scheiterte weiter. Jetzt `enableMailboxForExistingEmail` (`-mailbox true -passwd ...`), deckt beide FĂ€lle idempotent ab. - Tab-Leiste bei vielen Tabs: `flex-wrap` statt Overflow aus dem Rahmen. - Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.). - [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. - **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/ Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro Zeile (vor der MĂŒlltonne): Ă€ndert Beziehung und/oder Gegen-Kunden; neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel- Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE 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 `