Commit Graph
234 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 c39d252f5f Stressfrei-Mailbox: Postfach-Passwort beim Anlegen verbindlich setzen
Beim Anlegen einer Adresse mit echter Mailbox (IMAP/SMTP) wird das
frisch generierte Passwort jetzt immer 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 frueh
mit success zurueck, ohne je ein Postfach-Passwort zu setzen. Im CRM
lag dann ein verschluesseltes Passwort, das der Provider nicht kannte
-> IMAP/SMTP-Login schlug fehl. Jetzt stimmen CRM und Provider ueberein.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:13:08 +02:00
duffyduckandClaude Opus 4.8 81cd284cc4 Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag
Beziehungs-Dropdown erweitert um Schwiegertochter/Schwiegersohn,
Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa (Whitelist
front- und backend synchron).

Bearbeiten-Stift pro Zeile (vor der Muelltonne): Beziehung und/oder
Gegen-Kunde aenderbar. Neuer PUT /:customerId/referrals/:referralId
mit Whitelist-Pruefung, Doppel-Werber-409 (Self-Ausschluss),
Portal-Block und UPDATE-Auditeintrag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:57:04 +02:00
duffyduckandClaude Opus 4.7 cda6d2814e Kundenakte: Tab "Geworben / angeworben" (Kundenempfehlungen)
Neuer Tab vor "Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht. Zwei Abschnitte:
1. "<Kunde> wurde an Board geholt durch:" – max. 1 Werber
   (DB-Unique auf recruitedId).
2. "<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.

Jede Zeile: Kunde per Lupe-Such-Modal (breite Suche über Name/
Kundennr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown. Löschen +
Externtab-Link zur Kundenakte pro Zeile.

Bidirektional aus EINEM Datensatz: "A geworben durch B" erscheint
automatisch bei B unter "hat geworben"; von beiden Akten
hinzufügbar/löschbar.

Backend: neues Model CustomerReferral (recruiter/recruited FKs,
recruitedId @unique, relationship) + Migration. Beziehungs-Whitelist
serverseitig; Self-Werbung + Doppel-Werber (409) abgefangen.
Portal-Token wird explizit geblockt (Defense-in-Depth, nicht nur
UI-Ausblendung). CREATE/DELETE auditiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 17:50:15 +02:00
duffyduckandClaude Opus 4.7 e8d996d07e 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 (normalizeMobileNetwork),
angewandt in Create- UND Update-Pfad (der Update-Spread reichte den
Wert vorher ungefiltert an Prisma durch). Unbekannte/leere Werte
werden zu null normalisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:41:19 +02:00
duffyduckandClaude Opus 4.7 b4dfede494 Rate-Limiting: IPv6-Bypass-Härtung via ipKeyGenerator
loginRateLimiter und staffPasswordReAuthLimiter keyten auf die volle
req.ip. Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block
(/56–/64) pro Request eine neue Adresse nehmen und so das Per-IP-Limit
umgehen – betrifft Login-Bruteforce-Schutz und den Passwort-Set-
Reauth-Limiter.

Fix: req.ip in beiden keyGenerator durch ipKeyGenerator() ersetzt
(express-rate-limit v7). IPv6 wird auf das Subnetz normalisiert
(Library-Default /56), IPv4 bleibt unverändert. Verifiziert: zwei
verschiedene IPv6 im selben /56 ergeben denselben Key.

Die Limiter ohne eigenen keyGenerator (Passwort-Reset, Consent)
normalisieren IPv6 bereits über den Library-Default – damit jetzt
konsistent. Ersetzt den verworfenen aria-WIP, neu auf aktuellem
Stand gebaut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:38:54 +02:00
duffyduckandClaude Opus 4.7 627cae9b3b Spam-Anhänge: klarer Fehler statt Junk-Ordner-Raterei (Pentest R124)
Nachtrag zum R124-Fix: statt bei nicht auffindbarem Junk-Ordner still
auf INBOX bzw. den geratenen String 'Junk' zurückzufallen, wird jetzt
ein klarer Fehler zurückgegeben – sonst könnte im Randfall (Junk-Ordner
serverseitig entfernt/umbenannt) doch wieder die falsche UID aus dem
falschen Ordner gezogen werden.

- 4 Controller-Funktionen (downloadAttachment, saveAttachmentTo,
  saveAttachmentAsInvoice, saveAttachmentAsContractDocument):
  404 "Postfach hat keinen Spam-/Junk-Ordner (mehr)".
- 2 Service-Funktionen (moveEmailToTrash, restoreEmailFromTrash):
  klare error-Message im TrashOperationResult.

Kein undefined mehr Richtung IMAP-Lib.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:30:49 +02:00
duffyduckandClaude Opus 4.7 0a0cbe0e53 Spam-Tab: Anhänge aus dem echten Junk-Ordner holen (Pentest R124)
Beim Spam-Feature wurden moveEmailToTrash/restoreEmailFromTrash auf
den echten Junk-Pfad umgestellt, vier Attachment-Funktionen im
Controller aber nicht: 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: bestenfalls 404, schlimmstenfalls (UID-Kollision
INBOX vs. Junk) der FALSCHE Anhang aus INBOX – der dann z.B. als
Rechnung/Vertragsdokument abgelegt würde. Kein Cross-Customer-Leak
(gleicher Kunde/Postfach), aber Datenintegritätsproblem.

Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in
moveEmailToTrash (email.folder === 'SPAM' → findJunkFolderPath).
findJunkFolderPath war in dem Controller noch nicht importiert.

Vom Pentester (R124) gefunden – beim ursprünglichen Feature übersehen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:26:21 +02:00
duffyduckandClaude Opus 4.7 61c33a993b E-Mail-Client: unbekannter folder-Param defaultet auf INBOX
Kleine Nachhärtung zum Spam-Tab: in getCachedEmails blieb where.folder
bei einem unbekannten folder-Wert ungesetzt und mischte alle Ordner
des Postfachs. Jetzt defaulten unbekannte/fehlende Werte klar auf
INBOX. Ownership-Scope (customerId/stressfreiEmailId) war nie
betroffen – rein Ordner-Filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:59:24 +02:00
duffyduckandClaude Opus 4.7 dfb4dadae1 E-Mail-Client: Spam-Ordner als eigener Tab
Neuer Tab "Spam" (zwischen Gesendet und Papierkorb), zeigt den Junk-/
Spam-Ordner des gewählten Postfachs – damit fälschlich als Spam
einsortierte Mails auffindbar sind.

Backend:
- EmailFolder-Enum um SPAM erweitert (Migration, Wert angehängt →
  kein Rewrite bestehender Zeilen).
- imapService.findJunkFolderPath: ermittelt den Junk-Ordner per
  Special-Use-Flag \Junk + üblicher Namensliste (Junk/Spam/…).
- syncAllFoldersForAccount synct den Junk-Ordner zusätzlich als
  dbFolder=SPAM (syncEmailsForAccount bekommt dbFolder-Option).
- getCachedEmails + getFolderCountsForAccount um SPAM erweitert.
- Papierkorb-Move/Restore für Spam-Mails nutzt den echten Junk-Pfad
  als Quell-/Zielordner.

Frontend: Tab + Badge (ungelesen/gesamt), gleicher List-/Detail-Pfad
wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:52:24 +02:00
duffyduckandClaude Opus 4.7 8eb3790e7b Mobilfunk: Feld "Mobilfunknetz" unter Anbieter & Tarif
Neues Dropdown "Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
sichtbar bei Vertragstyp Mobilfunk. Optionen: Bitte auswählen (leer),
Telekom, Vodafone, Telefónica.

Neues Feld MobileContractDetails.mobileNetwork (String nullable,
speichert TELEKOM/VODAFONE/TELEFONICA) + idempotente Migration
(ADD COLUMN IF NOT EXISTS). String statt Enum, damit weitere Netze
ohne Migration ergänzbar sind. Anzeige in der Vertragsansicht mit
lesbarem Netz-Namen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 13:07:56 +02:00
duffyduckandClaude Opus 4.7 8508bdc38e Lieferbestätigung: eingegebenes Datum überschreibt Vertragsbeginn
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 übernimmt jetzt IMMER den Vertragsbeginn (die Liefer-
bestätigung ist das maßgebliche tatsächliche Startdatum), auch
überschreibend. Der Fallback "heute" (kein Datum angegeben) füllt
weiterhin nur ein leeres Feld, damit ein echtes Datum nicht
versehentlich mit heute überschrieben wird. No-op-Guard + Audit-Log
unverändert.

Frontend schickte das Datum bereits mit und lädt den Vertrag nach
dem Upload neu – kein Frontend-Change nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 12:49:55 +02:00
duffyduckandClaude Opus 4.7 e775b0e439 Pentest R121: Audit-Verify-Fehlalarm bei leerem resourceId beheben
POST /api/audit-logs/verify meldete ~73% der Einträge als
"manipuliert" – kein echtes Tampering, sondern ein Serialisierungs-
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 steht der
Wert aber als NULL; verifyIntegrity/rehashAll lasen null zurück und
JSON.stringify({resourceId:null}) schrieb ihn rein → anderer Hash
→ Fehlalarm für jede Zeile mit leerem resourceId. Die vom Pentester
gefundene durationMs-Korrelation war nur ein Proxy (Middleware-
Einträge haben durationMs UND oft kein resourceId).

Fix: generateHash lässt nullish resourceId weg – reproduziert exakt
das historische Schreibverhalten (undefined → Key weg). Kein Caller
übergibt je null explizit (verifiziert), daher matchen alle
Bestands-Hashes ohne Rehash; Feldreihenfolge unverändert.

Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära (id>4141)
482/482 valide (vorher wurden alle 289 leeren-resourceId-Zeilen
falsch geflaggt).

Separat/vorbestehend (NICHT dieser Fix): Einträge vor Commit
fd55742 ("complete new audit system") nutzen ein altes Hash-Schema
und brauchen einmalig POST /api/audit-logs/rehash zum Re-Baselinen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 09:13:18 +02:00
duffyduckandClaude Opus 4.7 094887f1d3 Pentest R120: Audit-Log /:id mit nicht-numerischer ID → 400 statt 500
Beim Suchen eines verify-integrity-Endpoints stiess der Pentester
auf einen 500er: GET /api/audit-logs/verify matcht GET /:id (das
echte Verify ist POST /verify), parseInt("verify") = NaN → Prisma
findUnique({ where:{ id: NaN }}) wirft. Gleiche 400-statt-500-
Klasse wie R64.1/R104.1.

Fix: Number.isNaN-Guard in getAuditLogById, getAuditLogsByCustomer
und updateRetentionPolicy → sauberer 400 „Ungültige ID".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 23:00:28 +02:00
duffyduckandClaude Opus 4.7 42705145dd Pentest R120: canAccess-403 zusätzlich tamper-evident auditieren
Der Retest deckte auf, dass abgewehrte Cross-Customer-Zugriffe
(canAccess*-403) nur im SecurityEvent-Monitoring-Stream landeten
(ACCESS_DENIED, /api/monitoring/events) – nicht im AuditLog, wo
der Pentester suchte. Der Monitoring-Stream ist zudem löschbar
(DELETE /api/monitoring/events) und nicht hash-verkettet.

emitAccessDenied schreibt jetzt zusätzlich einen tamper-evidenten
AuditLog-Eintrag (action READ, resourceType 'AccessDenied',
Sensitivity HIGH), der ein Clearen des Monitoring-Streams
überlebt und über die Hash-Kette manipulationssicher ist. Gilt
für alle canAccess*-403 (Contract + Customer, inkl. Vollmacht-
fehlt-Fall). Kein Enum-/Schema-Change: READ + distinktiver
resourceType, filterbar via searchAuditLogs.

Korrigiert damit auch meine frühere ungenaue Aussage „landet im
Audit" – vorher war das die falsche Tabelle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 22:51:40 +02:00
duffyduckandClaude Opus 4.7 23c530be46 Pentest R120 CRITICAL: IDOR auf Vertragsbaum-Endpunkt schließen
GET /api/contracts?tree=true&customerId=<fremd> gab Portal-Usern
den vollständigen Vertragsbaum beliebiger Fremdkunden zurück
(Name, Kundennummer, Vertragsnummern, Tarife – HTTP 200). Der
tree=true-Zweig returnte früh, bevor die Portal-User-customerIds-
Filterung griff, die für die flache Liste im selben Handler läuft.
Vorbestehender Bug; der includeDeactivated-Toggle machte ihn nur
sichtbarer (auch archivierte Fremdverträge kamen mit). Live vom
Pentester bestätigt, Gegentest ohne den Param = identisches Leck.

Fix: canAccessCustomer(req, res, customerId) vor dem frühen
Return. Prüft eigene ID + vertretene MIT Live-Vollmacht, sendet
selbst die 403. Staff passiert unverändert. Gleiches Muster wie
Pentest 56.3 bei update/delete.

Flacher Listenpfad war nie betroffen: getAllContracts bevorzugt
das serverseitig gesetzte customerIds-Scope gegenüber dem rohen
customerId-Query-Param.

Docs: SECURITY-HARDENING.md § Runde 120 + docs/todo.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 22:31:13 +02:00
duffyduckandClaude Opus 4.7 d059912c1e Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"
Der Vertragsbaum beim Kunden blendete DEACTIVATED-Verträge
komplett aus. Da der aktuellste Vertrag die Baumwurzel ist und
Vorgänger als Children hängen, verschwand eine ganze Kette aus
der Ansicht, sobald die Wurzel deaktiviert wurde – so „verschwand"
ein Vertrag scheinbar, als ein aktiver Folgevertrag gelöscht und
der Vorgänger vorher deaktiviert worden war.

getContractTreeForCustomer bekommt ein optionales
includeDeactivated-Flag (Default false = bisheriges Verhalten),
durchgereicht per Query-Param includeDeactivated=true. Toggle-
Button (Eye/EyeOff) im Kunden-Vertragstab; showDeactivated ist
Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte
behalten ihr graues DEACTIVATED-Badge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 21:45:58 +02:00
duffyduckandClaude Opus 4.7 d0611b740a Vertrag-Löschen: Kette bleibt intakt, Liste refresht
Zwei zusammenspielende Bugs:

1) Backend: deleteContract-Reihenfolge scheiterte am UNIQUE-
   Constraint auf Contract.previousContractId. Beim Middle-Delete
   (A → B → C, B löschen) hielt B im UPDATE-Moment noch selbst
   previousContractId=A – der Versuch, C ebenfalls auf A
   umzubiegen, warf MariaDB Duplicate-Entry, das UPDATE brach ab.

   Fix: Transaction, umgedrehte Reihenfolge – erst B löschen
   (DB-Cascade ON DELETE SET NULL räumt C.previousContractId ab,
   gibt A-Slot frei), dann C sauber auf A umbiegen.

2) Frontend: deleteMutation invalidierte weder Contract-Listen
   noch Kunden-Vertragsbaum und hatte keinen onError-Handler.
   Nach dem Delete wurde nach /contracts navigiert, dort zeigte
   der stale Cache noch den gelöschten Vertrag – Eindruck: „ganze
   Historie weg". Bei Bug 1 fehlgeschlagen brach das schweigend
   ab.

   Fix: queryClient.invalidateQueries für ['contracts'],
   ['contract-tree', customerId], ['customer', customerId] nach
   Erfolg. onError-Toast bei Fehler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 20:52:02 +02:00
duffyduckandClaude Opus 4.7 66fb5092fe Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event loggen
Auf Wunsch des Pentesters (R117-Nachtrag). Das Umschalten des
portalCredentialsNotRequired-Flags emittiert jetzt zusätzlich zum
generischen Contract-Update-Diff ein dediziertes UPDATE-Event
unter resourceType 'ContractPassword' – landet damit in derselben
CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.

Motivation: das Setzen des Flags räumt server-seitig
portalUsername, portalPasswordEncrypted und stressfreiEmailId auf
NULL. Diese Löschung soll unabhängig vom generischen Diff sofort
sichtbar sein. Rücknahme wird ebenfalls geloggt.

Details enthält alte + neue Flag-Werte plus Bool-Marker, welche
Anmeldedaten vor dem Opt-out belegt waren – keine Klartext-
Leckage im Log.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 14:58:51 +02:00
duffyduckandClaude Opus 4.7 dcdb3cb057 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out
Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars.
Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangs-
daten fehlen" für diesen Vertrag – für Anbieter ohne Portal oder
Kunden, die bewusst keine Zugangsdaten pflegen. Verstopft das
Cockpit sonst dauerhaft.

Neues Feld Contract.portalCredentialsNotRequired (Boolean, default
false) + idempotente Migration (ADD COLUMN IF NOT EXISTS). Bestand
bleibt auf false, Warnung greift wie bisher.

Beim Umschalten auf Opt-out werden portalUsername,
portalPasswordEncrypted und stressfreiEmailId server-seitig
explizit auf NULL gesetzt – Datenhygiene, damit keine verwaisten
Anmeldedaten in der DB stehen bleiben.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-17 14:45:32 +02:00
duffyduckandClaude Opus 4.7 b099297e11 Cockpit: DEACTIVATED + CANCELLED skippt Fristen-Warnungen
Bug: Ein DEACTIVATED-Vertrag (manuell archiviert) zeigte im
Cockpit weiter "Vertragsende: Vertrag seit 79 Tagen abgelaufen!"
– sinnlos, weil der Mitarbeiter den Vertrag ja aktiv beendet
hatte.

Fix: skipDeadlines-Guard, der bisher nur ONGOING abfing, greift
jetzt auch bei DEACTIVATED und CANCELLED. Beide sind terminale
Status – laufende Fristen ergeben da semantisch keinen Sinn.

Bewusst weiterhin AKTIV:
- Block 13a (Schlussrechnung fehlt): feuert wegen dieser Status,
  nicht trotzdem.
- Block 8 (DSGVO/Consent): läuft pro Kunde, nicht pro Vertrag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 17:08:54 +02:00
duffyduckandClaude Opus 4.7 217aa82b2a Pentest R111: contract-categories – Permission-String angeglichen
Pentester meldete 403 auf POST/PUT/DELETE /api/contract-categories/*
für Admin trotz vorhandener contract-categories:*-Perms. Vermutete
Regression aus R110, war aber schon seit dem allerersten Commit
so: Route verlangte developer:access, während der Permission-Seed
den Admins contract-categories:* gibt.

Fix: Route auf contract-categories:create/update/delete umgestellt,
konsistent mit den sechs Peer-Katalog-Endpunkten (platforms/
tariffs/cancellation-periods/… nutzen alle ressourcen-spezifische
Perms). Kommentar aktualisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 16:35:47 +02:00
duffyduckandClaude Opus 4.7 da3ae282fc Pentest R110: Mass-Assignment-Whitelist auf 7 Katalog-Endpunkten
MEDIUM: PUT /api/stressfrei-emails/:id und 6 weitere Update-
Endpunkte (platform, tariff, contractCategory, cancellationPeriod,
contractDuration, email-providers) reichten req.body ungefiltert
an Prisma. Gleiche Bug-Klasse wie das gefixte M1-Finding, sieben
Stellen mehr. Nachgewiesen via provisionError-Feld ausserhalb des
TS-Types.

Fix: sieben Whitelists + pickXxxUpdate()-Helper in sanitize.ts,
in den jeweiligen Controllern eingehängt. Reuse der bewährten
pick()-Infrastruktur (Customer/User seit Runde 7).

EmailProvider bewusst OHNE stripHtmlFromStrings, weil Passwörter
und API-Keys legitim Sonderzeichen enthalten dürfen.

Doku: SECURITY-HARDENING.md § Runde 110 + docs/todo.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 16:16:49 +02:00
duffyduckandClaude Opus 4.7 f2703ed6b7 IMAP-Fehler: zentraler Humanizer + Auth-Fehler explizit
Der User sah beim Postfach-Sync stumpf "Command failed" – realer
Grund war ein abgelaufenes Postfach-Passwort. Der Bug betraf
Anhang-Download UND Sync und war der gleiche wie 2 Commits zuvor:
imapflow wirft `new Error('Command failed')` und legt Details in
`.responseText`/`.responseStatus` ab, wir haben sie nirgends
gelesen.

Fix: humanizeImapError() als zentraler Helper in imapService:
- Extrahiert responseText/responseStatus aus imapflow-Errors
- Erkennt Auth-Fehler ("authentication failed", "invalid
  credentials", NO+auth) und liefert klare Meldung mit
  Handlungsanweisung: "Passwort stimmt nicht mehr, bitte
  Zugangsdaten synchronisieren".
- Deckt zusätzlich Netzwerk/TLS/Timeout/UID-Stale ab.

Angewendet auf:
- fetchAttachmentInner (Anhang-Fetch)
- downloadAttachment-Controller (Response an UI)
- syncEmailsForAccount (Sync-Ergebnis + Toast)

Damit sieht der User im Toast künftig statt "Command failed" die
tatsächliche Ursache – gleicher Mechanismus für Sync und
Anhang-Download.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 15:28:43 +02:00
duffyduckandClaude Opus 4.7 be19e60a1b Email-Anhang: echte IMAP-Fehlermeldung durchreichen
Bug: Anhang-Download schlug mit "Fehler beim Herunterladen des
Anhangs: Command failed" fehl – ohne jeden Kontext, warum.

Ursache: imapflow wirft bei jedem IMAP-NO/BAD-Response nur
`new Error('Command failed')` und legt den echten Server-Grund
in `.responseText` / `.responseStatus` ab. Unser Code las nur
`.message` und verlor damit alle Information.

Fix:
- fetchAttachmentInner extrahiert responseText/responseStatus
  aus dem imapflow-Error und packt sie in die geworfene Meldung.
- downloadAttachment-Catch macht das gleiche, damit auch andere
  IMAP-Fehler (mailboxOpen, connect) mit sinnvollem Text
  durchkommen.
- Zusätzlicher Friendly-Mapping-Fall für "no longer exist" /
  "no such message" → klare Meldung, dass die E-Mail-Liste
  neu synchronisiert werden sollte.

Damit sieht der User im UI jetzt z.B. "NO Some of the requested
messages no longer exist" statt "Command failed" und weiß, dass
ein Resync hilft.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-11 14:32:42 +02:00
duffyduckandClaude Opus 4.7 616cb77c0d Bankkarte: optionales Beschreibung-Feld
Neues optionales Freitext-Feld `description` (TEXT NULL) auf
BankCard – z.B. "Geschäftskonto", "gemeinsames Konto mit Partner".
Migration mit IF NOT EXISTS.

Backend service create/update nimmt description entgegen.
Frontend:
- BankCard-Type um description ergänzt.
- BankCardModal: neue Textarea zwischen Ablaufdatum und Aktiv-
  Checkbox mit Placeholder-Hilfetext.
- Bank-Kartenübersicht zeigt die Beschreibung kursiv unter den
  bestehenden Feldern, falls gesetzt (whitespace-pre-line für
  mehrzeilige Notizen).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 12:22:17 +02:00
duffyduckandClaude Opus 4.7 e49133fd64 Pentest R104.1: Salutation-Endpunkte 404 statt 500
INFO-Finding: GET/PUT /api/customers/:id/salutation-preference
warfen bei nicht-existierendem Customer 500 statt 404.

canAccessCustomer prüft für Staff-User nur den Portal-Flag, kein
Existenz-Check. Der Service warf `new Error('Kunde nicht
gefunden')`, was der Controller-Catch generisch auf 500 mappte.
set/clear kamen als Prisma-P2003 (FK-Constraint) durch.

Fix:
- Neuer Helper assertCustomerExists() im customer.service, wirft
  ApiError(404, ...). Wird von get/set/clearSalutationPreference
  aufgerufen.
- Die drei Controller-Catches respektieren jetzt
  ApiError.statusCode → 404 kommt sauber durch, alle anderen
  Fehler bleiben bei 500.

Doku: docs/todo.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 09:19:42 +02:00
duffyduckandClaude Opus 4.7 3c190e43fb Pentest R102: contractNumber-Whitelist nachgezogen
INFO-Finding: die interne CRM-Vertragsnummer war im Update-Path
nicht durch validateContractIdentifier abgedeckt. <script>… →
stripHtml → leerer String → Vertragsnummer silent überschrieben
(R87.1-Pattern auf einem sechsten Feld).

Fix: contractNumber zu CONTRACT_IDENTIFIER_FIELDS hinzugefügt.
Bestehende Whitelist (^[A-Za-z0-9_\-/. ]{0,100}$) und Raw-Input-
Validierung greifen automatisch. Legitime generierte Nummern
(GAS-MPVJ4P10FE8) bleiben OK, <script>/Overflow/CRLF/Tags → 400.

Kein Migration- oder Frontend-Change – der Fix nutzt den
bestehenden R86-Mechanismus.

Doku in SECURITY-HARDENING.md § Runde 102 und docs/todo.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 00:40:08 +02:00
duffyduckandClaude Opus 4.7 57959cd782 Kunde: persönliche Du/Sie-Präferenz pro Mitarbeiter
Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).

Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
  Logik. Response enthält immer effektive Präferenz + `source`
  ('user' | 'customer-default'), damit die UI den Standard-Text
  anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
  /:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
  greift.

Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
  und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
  Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
  Mitarbeiter", damit die Semantik der beiden Felder klar ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 00:34:51 +02:00
duffyduckandClaude Opus 4.7 d14db3de2f Vertragsauswahl: Kundennr./Zählernr./Telefonnr. in allen Pickern
Zentraler Helper contractLabel.ts liefert je nach Vertragstyp die
sinnvollen Zusatzinfos (nur wenn Daten vorhanden):
- Kdnr. <customer.customerNumber> (immer)
- Anbieter-Vtr. <contractNumberAtProvider>
- Zähler <meterNumber> (Strom/Gas)
- Mobil <main-SIM-Nummer> (Mobilfunk)
- Festnetz <main-phone> (Internet)

Backend contractApi.getAll include um energyDetails.meter und
internetDetails.phoneNumbers erweitert – vorher fehlten die im
Response.

Angewendet auf 5 Call-Sites:
- AssignToContractModal (Email-zu-Vertrag zuordnen)
- TaskList (Mitarbeiter- und Kundenportal-"Neue Aufgabe/Anfrage")
- Dashboard (Support-Anfrage-Modal)
- PdfTemplates (Test-Vorlage-Kontext-Auswahl)
- ContractForm (Vorgänger-Vertrag-Select)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 00:26:42 +02:00
duffyduckandClaude Opus 4.7 0b78323c7b Vertrag: neuer Status ONGOING (Läuft weiter auf unbestimmte Zeit)
Enum-Wert ONGOING neu, MariaDB-Migration (ans Ende gehängt, damit
keine Rows umgeschrieben werden). Prisma Client neu generiert.

Cockpit-Logik: ONGOING wird in die Query mit reingeholt (damit
DSGVO-/Consent-Warnungen weiter greifen), aber alle Fristen-
Blöcke pro Vertrag geskippt:
- Kündigungsfrist
- Vertragsende
- Zwischenrechnung-Frist

Kein Skip für Daten-Qualität (fehlende Adresse, Bank, Portal-
Zugang etc.) – die sind auch für unbefristete Verträge relevant.

Frontend: statusLabels/statusVariants/statusDescriptions in
ContractDetail, ContractList, CustomerDetail und
ContractDetailModal um ONGOING ergänzt. Status-Select im
ContractForm bekommt die neue Option zwischen ACTIVE und
CANCELLED.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 00:22:03 +02:00
duffyduckandClaude Opus 4.7 4d3382b5a4 Vertreter: Portal-Pflicht raus, Warn-Badge stattdessen
Suche und Add haben bisher nur Kunden mit portalEnabled=true zugelassen –
das machte das Feature quasi unnutzbar, weil die meisten Kunden kein
Portal aktiviert haben. Der Zugriff ist ohnehin erst dann effektiv,
wenn der Vertreter ein Portal-Konto bekommt.

- searchCustomersForRepresentative: portalEnabled-Filter raus, dafür
  Feld im select mitgeliefert.
- addRepresentative: Portal-Pflicht-Check raus.
- getRepresentedByList: portalEnabled im rep-Include, damit die UI
  auch für schon hinterlegte Vertreter das Badge zeigen kann.
- CustomerDetail: gelbes "Portal inaktiv"-Badge in Suchergebnissen
  und in der Vertreter-Liste. Hinweistext geändert.
- CustomerSummary-Type: portalEnabled? ergänzt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 08:53:02 +02:00
duffyduckandClaude Opus 4.7 818f801939 Pentest R101.1: Inline-Preview-Pfad refaktoriert + Diagnose-Log
R101.1 INFO/funktional: Pentester sieht Content-Disposition:
attachment auch bei ?disposition=inline. Die Logik im Controller
ist korrekt und liefert beim Direkttest gegen echte PDFs
application/pdf, der Pfad lässt sich aber in Prod nicht
reproduzieren.

Refaktoriert:
- Magic-Byte-Check in detectSafeContentType() extrahiert
- File-Descriptor wird in finally garantiert geschlossen
- Short-Read-Fälle (bytesRead < n) explizit geguardet
- console.warn wenn inline angefragt aber Magic-Byte-Mismatch
  oder Read-Crash – damit der Fall in Prod-Logs sichtbar wird
  falls er wieder auftritt

Sicherheits-Verhalten unverändert: Mismatch → attachment
(Stored-XSS-Schutz aus R30.13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 20:54:16 +02:00
duffyduckandClaude Opus 4.7 1680dcb0fe Pentest R97: Attachment-Validierung im Send-Handler
R97.1 LOW: malformed content (null, fehlend, true, "") landete
mit rohem Buffer.from()-Fehlertext in der Response; "" liess
sogar 0-Byte-Anhänge durch.
R97.2 INFO: keine App-Level-Caps für Größe/Anzahl – die im
Frontend dokumentierten 10/25 MB hingen am bodyParser.

Fix: validateAttachments() läuft VOR sendEmail() im Controller:
- max 25 Anhänge
- filename non-empty String, content non-empty Base64, optionaler
  contentType als String
- 10 MB pro Datei, 25 MB total (Größen-Schätzung über base64-Länge,
  kein Buffer.from während Validierung)

Harte 400 mit klarer Meldung. Sanity-Test 18/18 grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 00:45:59 +02:00
duffyduckandClaude Opus 4.7 4ab0340473 Pentest R95: portalUsername (Manual-Modus) härten
R95.1 MEDIUM: foo\r\nBcc:evil@x.de → Header-Injection-Vektor
R95.3 LOW: <script>...</script>@x.de → silent stripHtml-Mutation
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. Raw-Input vor stripHtml
geprüft (R87-Pattern). Eingehängt in sanitizeContractBody.

R95.2 (Email-Format-Pflicht) bewusst NICHT übernommen:
portalUsername ist im Manual-Modus nicht zwingend eine Email
(Vodafone, 1&1, EWE und Stadtwerke nutzen Kundennummern oder
Pseudonyme als Portal-Login). Doku in SECURITY-HARDENING.md
§ Runde 95.

Frontend: maxLength={100} am Input als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:24:57 +02:00
duffyduckandClaude Opus 4.7 c013e1e747 Pentest R93: Leerer String != fehlender Query-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 in parsePositiveIntQuery: nur `v === undefined` ist absent;
'', '  ', alles andere muss parsen. Required + optional Modes
unverändert. Sanity-Test: alle 11 Cases grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:54:15 +02:00
duffyduckandClaude Opus 4.7 caa283e66f Pentest R92: Strict-400 für accountId auf Vertrags-Endpunkten
R91-Fix war silent-undefined bei invaliden Werten – accountId=abc
auf Vertrags-Endpunkten brach die Mailbox-Isolation (Mails aus
allen Postfächern statt 400). Pentester R92 hat zu Recht
Strict-400 vorgeschlagen.

Helper parsePositiveIntQuery() bekommt { required } option:
- optional (default): fehlend → undefined (kein Filter), invalid → 400
- required: fehlend ODER invalid → 400

Vertrags-Endpunkte (Emails + Folder-Counts) auf required gestellt.
Customer-/Trash-Endpunkte bleiben optional (Cross-Mailbox-View ist
legitim), aber invalid → 400. Frontend hat eh enabled-Guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:47:42 +02:00
duffyduckandClaude Opus 4.7 18a2e1173b Pentest R91: NaN-Bypass auf accountId-Query-Param
R91.1 LOW: parseInt('abc') = NaN → der Ternary gab NaN an den
Service, if (NaN) ist falsy → Postfach-Filter fiel weg. Portal-
User mit ungültigem accountId sah Mails aus allen Postfächern
des Kunden für seinen Vertrag (canAccessContract greift weiter,
kein Cross-Customer-Leak).

Fix: zentraler parsePositiveIntParam(), akzeptiert nur positive
Ganzzahlen aus Query-Strings. Eingesetzt auf allen 5 Endpunkten,
die accountId/contractId aus Query lesen – auch da, wo der
Pentester nicht getestet hat (Customer-Inbox, Trash-Count),
weil derselbe Pattern überall stand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:22:40 +02:00
duffyduckandClaude Opus 4.7 993f2d10f0 E-Mail-Ansicht: Postfach-Filter in Trash/Sent durchreichen
Bug: Im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-
Postfach (Gelöscht) wurden Mails aus ALLEN Postfächern angezeigt,
unabhängig vom ausgewählten Postfach. Im Vertrag fehlte zusätzlich
der Vertrags-Filter im Papierkorb.

Backend:
- getEmailsForContract akzeptiert accountId → stressfreiEmailId
- getTrashEmails (controller + service) nimmt {accountId, contractId}
- getFolderCountsForContract bekommt optional stressfreiEmailId und
  zusätzlich trash/trashUnread im Result

Frontend:
- API-Client (getForContract/getTrash/getContractFolderCounts) nimmt
  Filter entgegen
- ContractEmailsSection reicht selectedAccountId in alle drei Queries
  + queryKey durch. Trash-Badge kommt jetzt aus contract-scoped
  Counts statt account-globalem stressfreiEmailApi
- EmailClientTab reicht selectedAccountId in die Trash-Query durch

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 14:06:24 +02:00
duffyduckandClaude Opus 4.7 f02824fe7d Pentest R89: Provider-Adressfelder härten
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).

Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.

R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:35:56 +02:00
duffyduckandClaude Opus 4.7 8b10316683 Anbieter: Kontakt + Kündigung als Stammdaten
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.

Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 13:10:59 +02:00
duffyduckandClaude Opus 4.7 26959ec909 Pentest R87: Identifier-Whitelist vor stripHtml ziehen
R87.1 LOW: stripHtml lief im R86-Fix VOR der Whitelist.
`<b>bold</b>` ging als `"bold"` mit 200 OK durch,
`<script>…</script>` reduzierte auf leeren String → null in DB
→ vorheriger Wert ohne Fehlermeldung überschrieben.

Fix: validateContractIdentifier läuft jetzt direkt gegen den
Raw-Input für die fünf Identifier-Felder. Die strikte Whitelist
lehnt eh alles ab, was stripHtml normalerweise auffangen würde
(Tags, Schemes, Zero-Width, Homoglyphe, Percent-Encoding) –
Defense-in-Depth bleibt, nur ehrlich (400 statt silent-200).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 12:50:45 +02:00
duffyduckandClaude Opus 4.7 c8b86ca9a7 Pentest R86: Vertrags-Identifier max 100 + Charset-Whitelist
R86.1 LOW + R86.2 LOW: >999-Zeichen liefen in DB-Overflow (500
statt 400), Attribut-Injection (`foo" onerror=…` ohne
umschließenden Tag) überlebte stripHtml.

Fix: validateContractIdentifier() (max 100,
^[A-Za-z0-9_\-/. ]{0,100}$) in sanitize.ts, eingehängt in
sanitizeContractBody. Wirft ApiError(400, …). Literales Space
statt \s → kein CRLF/Tab → kein Header-Injection-Vektor in
CSV-/Mail-/PDF-Export. Greift auf alle fünf Identifier-Felder
(Provider + Sales-Platform). ContractForm-Inputs bekommen
maxLength={100} als UX-Schicht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 14:14:00 +02:00
duffyduckandClaude Opus 4.7 0b7bb89ebc Vertrag: Auftragsnummer Vertriebsplattform vor Kundennummer
Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL) mit
Migration 20260619100000_contract_order_number_at_sales_platform
(IF NOT EXISTS). Form-Input, Detail-Zeile mit Copy-Button,
Audit-Mapping, Renewal-Copy und XSS-Strip-Allowlist analog zu
den bestehenden Sales-Platform-Feldern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 13:49:04 +02:00
duffyduck dfe2a4b241 Plesk-Sync: Auto-Import bei User-Remove deaktivieren
Folge-Bug zu 194c864: User löscht Adresse im Modal → DB-Liste
wird kürzer → Plesk-Sync läuft → Auto-Import sieht "c ist in
Plesk aber nicht in DB" → schreibt c zurück in
additionalForwardingEmails → Diff sagt nichts zu entfernen.

Ursache: Auto-Import (Pentest 83.x) lief für alle Sync-Pfade.
Beim Sync-Button ist Plesk→DB-Übernahme gewollt (Bestands-
Migration). Beim User-Add/Remove ist die DB-Liste die explizite
Intent – Auto-Import macht das User-Delete kaputt.

syncForwardingForEmail(id, opts?: { autoImportPleskMembers? })
mit Default true (Sync-Button-Verhalten). setAdditionalForwards
ruft mit false – entfernte Adressen verschwinden jetzt sauber
auch beim Provider.
2026-06-18 18:24:44 +02:00
duffyduck 194c86409f Plesk-Sync: del/add-Diff statt nicht-existierendem set:
Follow-up zu a83358b/24e152b. plesk bin mail --help auf Prod zeigt:
- -forwarding-addresses akzeptiert NUR add: und del:, kein set:
  → unser set:-Befehl wurde silent verworfen, Sync hatte nie
  Wirkung.
- -mailgroup als Option existiert gar nicht. Plesk nutzt -forwarding
  als Mailgroup-Schalter (im --info als "Mailgroup:" ausgegeben, im
  CLI als "-forwarding" gesetzt). Mein vorheriges -mailgroup false
  triggerte "Unrecognized option".

updateForwardTargets jetzt:
1. Aktuelle Members aus emailExists holen
2. Diff: toRemove = current \ targets, toAdd = targets \ current
   (case-insensitive)
3. Wenn toRemove: --update -forwarding-addresses del:<liste>
4. Wenn toAdd:    --update -forwarding true -forwarding-addresses add:<liste>

Idempotent, weil add/del Duplikate bzw. nicht-existente ignorieren.

Smoke-Test mit Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur bzirks@gmx.de hinzugefügt.
2026-06-18 18:16:57 +02:00
duffyduck 2becf6cb6a Plesk updateForwardTargets: CLI-Params + Response loggen
Sync zeigt im Prod-Log nur emailExists, kein update – entweder läuft
der Update-Code nicht durch oder Plesk lehnt ihn ab und wir sehen
es nicht (try/catch hat alles geschluckt).

CLI-Params vor dem Call loggen + Plesk-Response vollständig dumpen.
Zusätzlich Response auf code != 0 / stderr-Error prüfen statt
pauschal success=true zurückzugeben.
2026-06-18 18:07:12 +02:00
duffyduck 24e152b201 Pentest 83.1-83.3: Auto-Import-Pfad härten
83.1 MEDIUM: Auto-Import in syncForwardingForEmail rief
assertValidForwardingEmail nicht auf. Plesk-Member wie
attacker@plesk.internal wären ohne TLD-Block-Check (71.1) in
die DB importiert worden. Fix: jeder importierte Member läuft
durch assertValidForwardingEmail, ungültige werden silent gedroppt
+ auf debug-Level geloggt.

83.2 LOW: Self-Forward-Schutz (81.1) griff nur im Add-Pfad. Wenn
Plesk die eigene Adresse als Mailgroup-Member führte, wäre sie
beim Auto-Import in die DB-Liste gerutscht → nach dem Umschalten
auf Forwarding Mail-Loop. Fix: seenKeys mit der eigenen Adresse
initialisieren bevor die Import-Schleife läuft.

83.3 INFO: PII-Log auf console.debug umgestellt (statt console.log).

Smoke-Test mit gemischter Plesk-Liste: legitimer Member importiert,
reservierte TLDs + Self-Mail (exakt + Plus-Tag) abgelehnt,
Customer-Stamm + Default deduped.
2026-06-18 17:35:17 +02:00
duffyduck a83358bbe6 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht
Prod-Bug: zusätzliche Weiterleitung eintragen → Toast meldet
Erfolg, Plesk übernimmt nichts. Plesk hat zwei unabhängige
Verteil-Mechanismen, Mailgroup (alte CLI-Anlagen) und Forwarding
(neue). Unser Sync schrieb nur in Forwarding, die alte Adresse
lief aber via Mailgroup → set:-Befehle landeten in ungenutzter
Tabelle. Stage funktionierte, weil dort frisch im Forwarding-
Modus angelegt.

- EmailExistsResult um mailgroupActive/Members + forwardingActive/
  Targets erweitert.
- pleskProvider.emailExists parst alle vier Felder aus --info-
  stdout (Mailgroup: true|false, Group member(s): ..., Forward
  request: ...).
- pleskProvider.updateForwardTargets setzt -mailgroup false dazu –
  deaktiviert den Legacy-Mechanismus.
- syncForwardingForEmail holt vorm Plesk-Update die bestehenden
  Mailgroup-Members und Forwarding-Targets ab und importiert sie
  in unsere additionalForwardingEmails-Liste (canonical-Key-Dedup).
  Verlustfrei – kein Empfänger fällt beim Umschalten raus.

Smoke-Test mit echtem Plesk-stdout (User-Log): 3 Group-Members
sauber geparst, leeres "Forward request" als [] erkannt.
2026-06-18 17:22:08 +02:00
duffyduck 5bb048c534 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider
Bug: Die Stressfrei-Adresse selbst (max@stressfrei-wechseln.net)
konnte als zusätzliches Weiterleitungsziel eingetragen werden,
auch Plus-Varianten. Plesk leitet auf sich selbst um → Mail-Loop.

Backend setAdditionalForwards: lädt zusätzlich meta.email, vergleicht
canonicalEmailKey gegen canonicalEmailKey(meta.email). Bei Treffer
hartes ApiError(400) mit klarer "zeigt auf die Adresse selbst –
Mail-Loop"-Meldung statt silent dedup – der User soll merken, dass
sein Eintrag bewusst abgelehnt wurde.

Frontend AdditionalForwardsModal: zusätzliche proaktive Validierung
im Sub-Modal mit identischem canonicalize-Helper. Neuer selfEmail-
Prop, damit auch der Create-Modus (vor Persist) den Check fahren
kann. Spart Roundtrip + sofort sprechende Meldung.
2026-06-18 15:55:01 +02:00
duffyduck b3469483ca Pentest 77.3 (LOW): requireIdParam blockt Float-IDs
Number.isInteger(parseInt('4.5')) ist true, weil parseInt den
Nachkomma-Teil silent verwirft. /.../4.5/... traf die echte ID 4
statt 400 zu liefern – gleiches für 4.0 und Exp-Notation (4e1).

Fix: vor dem Parsen Regex /^\\d+$/ gegen die rohe Route-Eingabe.
Nur reine Ziffern erlaubt, keine Floats / Exp / Vorzeichen /
Whitespace / Hex.

Smoke-Test (17 Cases): 4.0, 4.5, 4e1, 4E2, 0, -4, +4, 0x10, 1.0e0,
leading/trailing Space alle abgelehnt; 1, 4, 100, 9999999
durchgewunken.
2026-06-18 15:28:59 +02:00