Compare commits

...

34 Commits

Author SHA1 Message Date
duffyduck b23ebeefc3 Vertrag-UI: Kunde-Link + Externtab + Info-Modal überall einheitlich
ContractDetail (Vertragsansicht): neben dem Kunden-Link sitzt jetzt
zusätzlich ein ExternalLink-Icon, das die Kundenakte in einem neuen
Tab öffnet. Info-Icon (Schnellansicht-Modal) bleibt wie gehabt.

ContractForm (Neuer/Bearbeiten-Vertrag): bekommt unter dem Heading
dieselbe Kunden-Zeile wie ContractDetail – Customer-Name als Link,
ExternalLink-Icon für neuen Tab, Info-Icon für die
CustomerInfoModal-Schnellansicht. Nur sichtbar wenn schon ein
Kunde gewählt ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 21:03:20 +02:00
duffyduck 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
duffyduck 386d206ff1 Vertragsdokumente-Modal: Vorschau-Link pro Dokument
Neben jeder Dokument-Zeile sitzt jetzt ein "Vorschau"-Link mit
ExternalLink-Icon, der die PDF in einem neuen Tab öffnet (via
viewUrl mit Token-Auth, inline-disposition). Klick darauf
schaltet bewusst NICHT die Checkbox um – die Auswahl bleibt,
nur das Dokument geht in einem zweiten Tab auf.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:56:16 +02:00
duffyduck 67d6fd4941 E-Mail-Liste: eigener Scrollbalken statt seitenweit wachsen
User-Bug: bei vielen E-Mails wuchs die Liste links unbegrenzt nach
unten, sodass die ganze Seite gescrollt werden musste.

- ContractEmailsSection: flex-Container von minHeight:400 auf
  feste 600px Höhe gestellt. Die linke Liste hatte schon
  overflow-y-auto – jetzt greift's auch.
- EmailClientTab: h-full auf calc(100vh - 240px) (mit
  minHeight:500) bounded. h-full hat im Tab-Container vorher
  nichts gebracht, weil der Parent selbst keine feste Höhe
  hatte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 07:51:16 +02:00
duffyduck 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
duffyduck a4895374b9 Kundendaten-Modal: nur Anbieter-Nummern, keine internen CRM-Nummern
Modal ist für Mails AN den Anbieter gedacht – interne CRM-Nummern
interessieren dort niemanden.

- formatCustomerBlock: customer.customerNumber (intern) raus,
  stattdessen contract.customerNumberAtProvider rein.
- formatContractBlock: interne contractNumber raus, restliche
  Anbieter-/Vertriebsplattform-Nummern bleiben.
- Previews ziehen ebenfalls auf customerNumberAtProvider /
  contractNumberAtProvider um, mit Hinweis-Text wenn keine
  Anbieter-Nummer hinterlegt ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:10:20 +02:00
duffyduck ebaee024b6 Kundendaten-Modal: Bank + Ausweis getrennte Text/PDF-Wahl
Bank- und Ausweis-Section haben jetzt jeweils zwei unabhängige
Checkboxen statt der bisherigen Section-Checkbox + Sub-Attach:

- Bank: "Letzte 4 IBAN-Stellen einfügen" + "Bankkarte als PDF
  anhängen". Text-Variante zeigt nur "IBAN endet auf: XXXX" – keine
  volle IBAN/BIC/Bank-Liste mehr (Mail-Hygiene).
- Ausweis: "{Typ}-Nummer einfügen" + "{Typ} als PDF anhängen".
  Text-Variante zeigt nur die Nummer, keine Behörde/Daten.

Alle drei Kombinationen "nur Text", "nur PDF" und "beides" sind
damit möglich, "keins von beidem" entspricht der Section-aus.
Schalter sind disabled wenn der jeweilige Wert (IBAN /
documentNumber / documentPath) nicht vorhanden ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:08:35 +02:00
duffyduck f1b05c56e5 Kundendaten-Modal: E-Mail-Wahl Stammdaten vs. Absender
In der "Anrede & Name"-Section neue Radio-Wahl, sobald die
Section aktiv ist:
- Stammdaten-E-Mail (customer.email) – default wenn vorhanden
- Absender-Adresse (Postfach von dem gesendet wird)
- Keine E-Mail einfügen

Wird in den Customer-Block-Builder durchgereicht und ersetzt die
fix verdrahtete customer.email-Zeile. Wenn die Stammdaten-Mail
fehlt, ist der Radio "Stammdaten" disabled und der Default
springt auf "Absender".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 16:04:54 +02:00
duffyduck 5293af18a5 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen
Zwei neue Buttons im Compose-Modal (nur sichtbar bei Vertrag-
Kontext):

- Vertragsdokumente: listet alle am Vertrag gespeicherten
  ContractDocuments gruppiert nach documentType. Auswahl →
  Token-Download via fileUrl → base64 → Anhang.
- Kundendaten einfügen: zeigt Sections nur wenn Daten vorhanden
  (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank,
  Ausweis). Bei Bank/Ausweis zusätzlich Sub-Checkbox "als PDF
  anhängen" wenn documentPath vorhanden. Text-Blöcke ans Body-
  Ende, PDFs in attachments[]. 25-MB-Limit beidseitig geprüft.

Helpers in composeAttachmentHelpers.ts:
- serverFileToAttachment(path, filename) für Token-URL→Blob→base64
- totalAttachmentBytes mit ~33% base64-Overhead
- sprechende Dateinamen via bankCardAttachmentName /
  identityDocAttachmentName

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:55:13 +02:00
duffyduck 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
duffyduck f1102a24b7 CopyButton: Portal-Benutzername + E-Mail-Postfach-Selector
ContractForm: neben dem "Portal Benutzername"-Label sitzt jetzt
ein CopyButton, der je nach Modus den manuellen Eingabewert oder
die ausgewählte Stressfrei-Adresse in die Zwischenablage kopiert.
Erscheint nur wenn der jeweilige Wert nicht leer ist.

EmailClientTab + ContractEmailsSection: rechts neben dem Account-
Selector liegt jetzt ein CopyButton, der die Postfach-Adresse des
aktuell gewählten Mailbox-Kontos kopiert. Funktioniert in beiden
UI-Varianten (Single-Account-Span und Multi-Account-Dropdown).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-21 15:05:25 +02:00
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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
duffyduck 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 9274c0adaf Doku: URL-encoded Route-Params als by-design dokumentiert (R85-INFO) 2026-06-18 18:54:24 +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
duffyduck 8992bb7a5d Stressfrei-Adressen: Duplikate beim Anlegen ablehnen
Bug: dieselbe E-Mail-Adresse konnte beim selben Kunden mehrfach
angelegt werden – im Screenshot zwei identische Einträge nach
einem Doppel-Submit.

- createEmail: findFirst auf (customerId, email) case-insensitive,
  bei Treffer ApiError(409). Eigene Meldung für inaktive
  Duplikate (Hinweis: alten Eintrag reaktivieren statt neu anlegen).
- updateEmail: gleicher Check beim Umbenennen, NOT id-Exclude.
- Controller: catch-Blöcke honorieren ApiError.statusCode (vorher
  pauschal 400) → 409 kommt sauber an die UI durch.
- Frontend: updateMutation bekam onError, damit der Fehler nicht
  schlucken bleibt.
2026-06-18 14:01:35 +02:00
duffyduck 246999be01 Pentest 71.1-71.4: Härtung der Zusatz-Weiterleitungen
71.1 MEDIUM: BLOCKED_TLDS-Set in assertValidForwardingEmail –
reservierte/private TLDs (local, internal, corp, lan, home,
private, invalid, test, localhost, example, intranet, localdomain,
arpa) werden abgelehnt. Schließt Plesk-DNS-Probing ins interne Netz.

71.2 LOW: canonicalEmailKey-Helper normalisiert Mail-Adressen für
den Dedup (Plus-Tag wegstrippen, lowercase). billing+x@y und
billing@y haben jetzt denselben Schlüssel – auch gegen Kunden-
Stamm-Mail und gegen config.defaultForwardEmail im sync-Pfad.

71.3 INFO: Neuer requireIdParam-Helper im Controller liefert 400
statt 500 bei nicht-numerischen Route-IDs. Alle acht parseInt-
Stellen umgestellt (auch über die gemeldete eine hinaus).

71.4 INFO: setAdditionalForwards rollt den DB-Stand zurück, wenn
syncForwardingForEmail mit dem Provider scheitert. Vorheriger Wert
wird vorm Update gemerkt und im Fehlerfall wieder eingespielt –
DB und Plesk laufen nicht mehr auseinander.

Smoke-Tests: 11 reservierte TLDs abgelehnt, 4 echte TLDs (de, com,
co.uk, museum) durchgewinkt, Plus-Tag-Strip mit Multi-Plus+Casing.
2026-06-18 13:41:16 +02:00
duffyduck 96a054aa1a Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen
Der "Weitere Weiterleitungen"-Button war bisher nur im Bearbeiten-
Modus sichtbar (provider-vorhanden + ID nötig). Jetzt erscheint er
auch im Anlegen-Modus, sobald "Beim E-Mail-Provider anlegen"
angehakt ist.

- Sub-Modal generalisiert: value/onChange-controlled.
  Mit email-Prop → API-Persist pro Änderung (Edit-Modus).
  Ohne email-Prop → reiner lokaler State (Create-Modus).
- Haupt-Modal trackt additionalForwards als eigenen State und
  ruft nach erfolgreicher createEmail einmalig
  updateAdditionalForwards mit der vollen Liste auf – ein zweiter
  Provider-Sync mit set: setzt die finale Liste.
- Counter-Badge am Button zeigt die Anzahl bereits eingegebener
  Adressen.
2026-06-18 11:20:03 +02:00
duffyduck 36beac98c9 Stressfrei-Adressen: zusätzliche Weiterleitungsziele
Pro StressfreiEmail können jetzt weitere Weiterleitungs-Adressen
gepflegt werden, die zusätzlich zur Stamm-E-Mail des Kunden und
zur globalen Default-Forward-Adresse an den Provider gepusht werden.

- Schema: StressfreiEmail.additionalForwardingEmails (TEXT/JSON-
  Array), Migration mit IF NOT EXISTS.
- syncForwardingForEmail liest die Zusatzliste mit und filtert
  Duplikate gegen customer.email + config.defaultForwardEmail
  (case-insensitive) raus.
- Neuer Endpoint PUT /api/stressfrei-emails/:id/additional-forwards
  mit Body { emails: string[] } – ersetzt die Liste komplett und
  syncht den Provider direkt nach. Hard-Cap 20 Adressen, Format-
  Validation per Regex, Audit-Log.
- Frontend: Button "Weitere Weiterleitungen" im Edit-Modus des
  StressfreiEmailModals (erscheint sobald die Adresse beim Provider
  vorhanden ist). Sub-Modal mit Liste + Add/Remove, Änderungen
  gehen sofort live.
2026-06-18 10:58:14 +02:00
duffyduck 60851450f6 Bugfixes: Zähler/Bankkarte/Ausweis/Zählerstand-Modal editierbar
Vier weitere Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix vom 2026-06-03: setFormData(getInitialFormData())
unbedingt im Render-Body, getriggert durch formData.X !== prop.X.
Jeder Tastendruck setzte den State zurück → kein Editieren möglich.

Fix in MeterModal (meterNumber), BankCardModal (iban),
IdentityDocumentModal (documentNumber), MeterReadingModal (value):
nach useEffect mit [entity?.id]-Dependency umgezogen.
2026-06-08 20:54:40 +02:00
duffyduck 523eab30d5 JpgToPdfModal: Bilder auf 2400px runterskalieren
Stage: 2 Handy-JPGs → 23 MB PDF. Smartphone-Fotos haben
4000-6000 px Kante, das macht auch ohne Re-Encode 5-10 MB pro
Bild → PDF wird riesig.

Beim Hinzufügen werden Bilder jetzt auf max 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default) persistiert. Vorschau, Rotation/
Flip und PDF-Embed laufen alle auf dem skalierten Bild.

Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF.
2026-06-03 18:29:04 +02:00
32 changed files with 3307 additions and 205 deletions
@@ -0,0 +1,7 @@
-- Zusätzliche Weiterleitungs-E-Mails pro StressfreiEmail-Adresse.
-- JSON-Array (z.B. `["info@partner.de","cc@kanzlei.de"]`), wird beim
-- Sync zusammen mit customer.email + config.defaultForwardEmail an den
-- Provider gepusht (`set:`-Befehl überschreibt die Liste).
ALTER TABLE `StressfreiEmail`
ADD COLUMN IF NOT EXISTS `additionalForwardingEmails` TEXT NULL;
@@ -0,0 +1,7 @@
-- Zusätzliches optionales Feld unter "Anbieter & Tarif": Auftragsnummer bei
-- der Vertriebsplattform (vor der Kundennummer). Plattformen liefern beim
-- Abschluss oft eine eigene Auftrags-/Vorgangsnummer, die fürs Reklamations-
-- handling gebraucht wird.
ALTER TABLE `Contract`
ADD COLUMN IF NOT EXISTS `orderNumberAtSalesPlatform` VARCHAR(191) NULL;
@@ -0,0 +1,13 @@
-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz.
-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter
-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt.
-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191).
ALTER TABLE `Provider`
ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL,
ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL,
ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL;
+16
View File
@@ -402,6 +402,12 @@ model StressfreiEmail {
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
// hinaus). Wird beim Sync zusammen mit customer.email +
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
additionalForwardingEmails String? @db.Text
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
createdAt DateTime @default(now())
@@ -570,6 +576,15 @@ model Provider {
portalUrl String? // Kundenkontourl (Login-Seite)
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
contactEmail String? // Allgemeine Kontakt-Emailadresse
contactPhone String? // Kontakt-Telefonnummer
contactFax String? // Kontakt-Faxnummer
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
cancellationEmail String? // Kündigungs-Emailadresse
cancellationFax String? // Kündigungs-Faxnummer
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
isActive Boolean @default(true)
tariffs Tariff[]
contracts Contract[]
@@ -687,6 +702,7 @@ model Contract {
tariffName String?
customerNumberAtProvider String?
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
priceFirst12Months String? // Preis erste 12 Monate
@@ -41,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
return isNaN(d.getTime()) ? undefined : d;
}
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
// Postfächern.
//
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
// Endpunkten reichte das silent-undefined nicht die Mailbox-Isolation
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
//
// Helper hat zwei Modi:
// - default (optional): fehlend/leer → undefined (kein Filter)
// invalid → 400
// - { required: true }: fehlend/leer → 400
// invalid → 400
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
// zurück; der Caller bricht dann mit `return` ab.
function parsePositiveIntQuery(
v: unknown,
fieldLabel: string,
res: Response,
options?: { required?: boolean },
): number | undefined | null {
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
// Trennung jetzt strikt:
// - Param fehlt komplett (`undefined`) → "absent"
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
if (v === undefined) {
if (options?.required) {
res.status(400).json({
success: false,
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
} as ApiResponse);
return null;
}
return undefined;
}
if (typeof v !== 'string') {
res.status(400).json({
success: false,
error: `${fieldLabel} muss als Zahl übergeben werden.`,
} as ApiResponse);
return null;
}
const trimmed = v.trim();
if (trimmed === '') {
res.status(400).json({
success: false,
error: `${fieldLabel} darf nicht leer sein bitte weglassen oder positive Ganzzahl angeben.`,
} as ApiResponse);
return null;
}
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
res.status(400).json({
success: false,
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
} as ApiResponse);
return null;
}
return n;
}
// E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
// ist erwünscht), aber invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
@@ -81,17 +149,26 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
}
}
// E-Mails für einen Vertrag abrufen
// E-Mails für einen Vertrag abrufen.
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
// ein ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
// Vertrags-Endpunkte sind per Design IMMER pro Postfach fehlt
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const emails = await cachedEmailService.getCachedEmails({
contractId,
stressfreiEmailId,
folder,
limit,
offset,
@@ -238,13 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
}
}
// E-Mail-Anzahl pro Ordner für einen Vertrag
// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach)
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
// Badge eine andere Zahl als die Liste).
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
res.json({ success: true, data: counts } as ApiResponse);
} catch (error) {
@@ -311,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
return false;
}
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
const MAX_ATTACHMENT_COUNT = 25;
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
interface AttachmentValidationError {
status: 400;
error: string;
}
function validateAttachments(
attachments: unknown,
): { ok: true } | AttachmentValidationError {
if (attachments === undefined) return { ok: true };
if (!Array.isArray(attachments)) {
return { status: 400, error: 'attachments muss ein Array sein.' };
}
if (attachments.length > MAX_ATTACHMENT_COUNT) {
return {
status: 400,
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
};
}
let totalBytes = 0;
for (let i = 0; i < attachments.length; i++) {
const a = attachments[i];
const label = `Anhang ${i + 1}`;
if (!a || typeof a !== 'object') {
return { status: 400, error: `${label} hat das falsche Format.` };
}
const filename = (a as Record<string, unknown>).filename;
const content = (a as Record<string, unknown>).content;
const contentType = (a as Record<string, unknown>).contentType;
if (typeof filename !== 'string' || filename.trim() === '') {
return { status: 400, error: `${label} hat keinen Dateinamen.` };
}
if (typeof content !== 'string' || content.length === 0) {
return {
status: 400,
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
};
}
if (!BASE64_RE.test(content)) {
return {
status: 400,
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
};
}
if (contentType !== undefined && typeof contentType !== 'string') {
return {
status: 400,
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
};
}
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
const approxBytes = Math.ceil(content.length * 0.75);
if (approxBytes > MAX_PER_FILE_BYTES) {
return {
status: 400,
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
};
}
totalBytes += approxBytes;
if (totalBytes > MAX_TOTAL_BYTES) {
return {
status: 400,
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
};
}
}
return { ok: true };
}
// E-Mail senden
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -327,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
return;
}
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
// (Format, Größe, Anzahl) sonst leakte der rohe Node.js-Fehler
// in die Response und Limits waren nur Frontend-Doku.
const attachmentCheck = validateAttachments(attachments);
if (!('ok' in attachmentCheck)) {
res.status(attachmentCheck.status).json({
success: false,
error: attachmentCheck.error,
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -882,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
// ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen
// Papierkorb-E-Mails für einen Kunden abrufen.
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
// beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
// vorher: alle gelöschten E-Mails des Kunden.
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
// View ist legitim), invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const emails = await cachedEmailService.getTrashEmails(customerId);
const emails = await cachedEmailService.getTrashEmails(customerId, {
stressfreiEmailId,
contractId,
});
res.json({ success: true, data: emails } as ApiResponse);
} catch (error) {
@@ -900,13 +1086,20 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
}
}
// Papierkorb-Anzahl für einen Kunden
// Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const count = await cachedEmailService.getTrashCount(customerId);
const count = await cachedEmailService.getTrashCount(customerId, {
stressfreiEmailId,
contractId,
});
res.json({ success: true, data: { count } } as ApiResponse);
} catch (error) {
+25 -1
View File
@@ -8,7 +8,7 @@ import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate } from '../utils/sanitize.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
@@ -36,6 +36,29 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') {
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
//
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
// sogar zu `null` und damit den vorherigen Wert überschreiben.
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
// Bypässe ab, die stripHtml normalerweise auffangen würde
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding)
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
if (parentKey && isContractIdentifierField(parentKey)) {
return validateContractIdentifier(body, parentKey);
}
// Pentest 95.1/95.3/95.4 (LOWMEDIUM, 2026-06-21): portalUsername
// (Manual-Modus) hatte gar keine Validierung CRLF/Header-Injection,
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
// Raw-Input-Pattern wie R87.
if (parentKey === 'portalUsername') {
return validatePortalUsername(body, parentKey);
}
return stripHtml(body);
}
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
@@ -204,6 +227,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
// Stored-XSS gerendert.
//
// Default: Content-Disposition: attachment → Browser lädt nur runter.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
// Bei Mismatch fällt's auf attachment zurück Stored XSS bleibt
// weiterhin unmöglich.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
// attachment zurück Stored XSS bleibt weiterhin unmöglich.
//
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
const wantsInline = req.query.disposition === 'inline';
let useInline = false;
let inlineContentType: string | null = null;
if (wantsInline) {
try {
const fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
useInline = true;
inlineContentType = 'application/pdf';
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
useInline = true;
inlineContentType = 'image/png';
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
useInline = true;
inlineContentType = 'image/jpeg';
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
useInline = true;
inlineContentType = 'image/gif';
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
useInline = true;
inlineContentType = 'image/webp';
}
} catch { /* ignore fällt auf attachment zurück */ }
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
if (wantsInline && !safeContentType) {
console.warn(
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
);
}
res.setHeader('X-Content-Type-Options', 'nosniff');
if (useInline && inlineContentType) {
res.setHeader('Content-Type', inlineContentType);
if (safeContentType) {
res.setHeader('Content-Type', safeContentType);
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
}
res.sendFile(absolute);
}
/**
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
* entspricht. Sonst `null` dann wird die Datei als attachment serviert.
*
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
*/
function detectSafeContentType(absolute: string): string | null {
let fd: number | null = null;
try {
fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
if (bytesRead >= 8
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) return 'image/png';
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
if (bytesRead >= 6
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
) return 'image/gif';
if (bytesRead >= 12
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
) return 'image/webp';
return null;
} catch (err) {
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
return null;
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
}
@@ -3,10 +3,33 @@ import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
import { ApiError } from '../utils/apiError.js';
// Pentest 71.3 (INFO): `parseInt(...)` ohne NaN-Check gab bei
// `/stressfrei-emails/abc/...` einen generischen 500 zurück.
//
// Pentest 77.3 (LOW): `Number.isInteger(parseInt(...))` ließ Floats
// und Exponential-Notation durch `4.0`, `4.5`, `4e1` werden alle
// zu `4` geparst und treffen die echte ID 4. Fix: erst gegen
// `/^\d+$/` validieren, dann erst parsen.
function requireIdParam(req: AuthRequest, res: Response, paramName: string): number | null {
const raw = req.params[paramName];
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed < 1) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
return parsed;
}
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customerId = requireIdParam(req, res, 'customerId');
if (customerId === null) return;
// requireCustomerAccess in der Route greift nicht ausreichend:
// Portal-User haben `customers:read` (für eigene Daten) und werden
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
@@ -26,7 +49,8 @@ export async function getEmailsByCustomer(req: AuthRequest, res: Response): Prom
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.getEmailById(emailId);
@@ -54,7 +78,8 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
export async function createEmail(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
const customerId = requireIdParam(req, res, 'customerId');
if (customerId === null) return;
const email = await stressfreiEmailService.createEmail({
...req.body,
customerId,
@@ -67,7 +92,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
});
res.status(201).json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(400).json({
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
@@ -76,7 +102,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
await logChange({
@@ -86,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
});
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(400).json({
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
} as ApiResponse);
@@ -95,7 +123,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
await stressfreiEmailService.deleteEmail(emailId);
await logChange({
@@ -114,7 +143,8 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
@@ -151,9 +181,59 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
}
}
/**
* Zusätzliche Weiterleitungs-E-Mails der StressfreiEmail neu setzen.
* Body: `{ emails: string[] }`. Liste ersetzt komplett, Provider wird
* unmittelbar nachgezogen.
*/
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const body = req.body ?? {};
if (!Array.isArray(body.emails)) {
res.status(400).json({ success: false, error: '`emails` muss ein Array sein.' } as ApiResponse);
return;
}
if (body.emails.length > 20) {
res.status(400).json({ success: false, error: 'Maximal 20 zusätzliche Weiterleitungen erlaubt.' } as ApiResponse);
return;
}
const result = await stressfreiEmailService.setAdditionalForwards(emailId, body.emails);
if (!result.success) {
res.status(400).json({ success: false, error: result.error } as ApiResponse);
return;
}
await logChange({
req,
action: 'UPDATE',
resourceType: 'StressfreiEmail',
resourceId: emailId.toString(),
label: `Zusatz-Weiterleitungen aktualisiert (${(result.forwardTargets || []).length} Ziele aktiv)`,
});
res.json({
success: true,
data: { forwardTargets: result.forwardTargets },
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
} as ApiResponse);
}
}
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const emailId = requireIdParam(req, res, 'id');
if (emailId === null) return;
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
if (!result.success) {
@@ -15,4 +15,8 @@ router.post('/:id/reset-password', authenticate, requirePermission('customers:up
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
// Zusätzliche Weiterleitungs-Ziele setzen (User-pflegbare Liste, zusätzlich
// zur Stamm-E-Mail des Kunden und der globalen Default-Forward-Adresse).
router.put('/:id/additional-forwards', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateAdditionalForwards);
export default router;
+57 -37
View File
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
}
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
export async function getFolderCountsForContract(contractId: number): Promise<{
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
// Optional auf ein bestimmtes Postfach einschränken. Bug 2026-06-21:
// vorher zählten die Badges Mails aus ALLEN Postfächern, während die
// Liste (nach Fix) nur die des ausgewählten Postfachs zeigt Badge
// und Liste liefen auseinander. Trash mit reingenommen, weil der
// Contract-Trash-Badge sonst wieder auf account-globalen Zähler
// zurückfallen müsste.
export async function getFolderCountsForContract(
contractId: number,
stressfreiEmailId?: number,
): Promise<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
trash: number;
trashUnread: number;
}> {
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
// INBOX total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
}),
// INBOX unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
}),
// SENT total
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false },
}),
// SENT unread
prisma.cachedEmail.count({
where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
}),
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
]);
return { inbox, inboxUnread, sent, sentUnread };
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
}
// Alle StressfreiEmails eines Kunden mit Mailbox
@@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise<TrashOperationRe
}
// Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
return prisma.cachedEmail.findMany({
where: {
// Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
// einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
// des Kunden, unabhängig von welchem Postfach man gerade angemeldet ist
// User-Bug 2026-06-21.
export async function getTrashEmails(
customerId: number,
options?: { stressfreiEmailId?: number; contractId?: number },
): Promise<CachedEmailWithRelations[]> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
stressfreiEmail: { customerId },
};
if (options?.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.findMany({
where,
include: {
stressfreiEmail: {
select: {
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
}) as Promise<CachedEmailWithRelations[]>;
}
// Papierkorb-E-Mails zählen
export async function getTrashCount(customerId: number): Promise<number> {
return prisma.cachedEmail.count({
where: {
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
export async function getTrashCount(
customerId: number,
options?: { stressfreiEmailId?: number; contractId?: number },
): Promise<number> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: true,
stressfreiEmail: {
customerId,
},
},
});
stressfreiEmail: { customerId },
};
if (options?.stressfreiEmailId) {
where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.count({ where });
}
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
+2
View File
@@ -203,6 +203,7 @@ interface ContractCreateData {
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
orderNumberAtSalesPlatform?: string;
customerNumberAtSalesPlatform?: string;
contractNumberAtSalesPlatform?: string;
priceFirst12Months?: string;
@@ -899,6 +900,7 @@ export async function createRenewalContract(previousContractId: number) {
tariffName: previousContract.tariffName,
customerNumberAtProvider: previousContract.customerNumberAtProvider,
contractNumberAtProvider: previousContract.contractNumberAtProvider,
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
portalUsername: previousContract.portalUsername,
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
let hasMailbox: boolean | undefined;
let mailgroupActive: boolean | undefined;
let mailgroupMembers: string[] | undefined;
let forwardingActive: boolean | undefined;
let forwardingTargets: string[] | undefined;
if (exists && result.stdout) {
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
if (mailboxMatch) {
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
}
// Mailgroup-Status + Mitglieder. Plesk listet sie auf einer
// Zeile, Adressen sind durch Whitespace getrennt.
const mailgroupMatch = result.stdout.match(/Mailgroup:\s*(true|false)/i);
if (mailgroupMatch) {
mailgroupActive = mailgroupMatch[1].toLowerCase() === 'true';
}
const groupMembersMatch = result.stdout.match(/Group member\(s\):\s*([^\n]*)/i);
if (groupMembersMatch) {
mailgroupMembers = groupMembersMatch[1]
.trim()
.split(/\s+/)
.filter((m) => m.includes('@'));
}
// Forwarding-Status + Ziele. Plesk druckt "Forward request: <addrs>".
// Auf manchen Plesk-Versionen heißt das Feld auch "Forwarding".
const forwardActiveMatch = result.stdout.match(/Forwarding:\s*(true|false)/i);
if (forwardActiveMatch) {
forwardingActive = forwardActiveMatch[1].toLowerCase() === 'true';
}
const forwardTargetsMatch = result.stdout.match(/Forward(?:ing)?(?: request)?:\s*([^\n]*)/i);
if (forwardTargetsMatch) {
forwardingTargets = forwardTargetsMatch[1]
.trim()
.split(/\s+/)
.filter((m) => m.includes('@'));
if (forwardingActive === undefined) {
forwardingActive = (forwardingTargets?.length ?? 0) > 0;
}
}
}
return {
exists,
email: exists ? email : undefined,
hasMailbox,
mailgroupActive,
mailgroupMembers,
forwardingActive,
forwardingTargets,
};
} catch (error) {
// HTTP-Fehler oder Netzwerkfehler
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
};
}
// Plesk CLI API: Weiterleitungsziele aktualisieren
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
// im CLI "forwarding" derselbe Mechanismus, doppelt benannt).
// Es gibt KEINE separaten Mailgroup-Optionen wie `-mailgroup`.
//
// Wir bauen daher den Diff: alte Member abrufen, dann
// del:<entfernt> + add:<neu> in zwei separaten Calls. Idempotent,
// weil add: Duplikate ignoriert und del: nicht-vorhandene auch.
const currentMembers = exists.mailgroupMembers ?? [];
const targetsLower = new Set(targets.map((t) => t.toLowerCase()));
const currentLower = new Set(currentMembers.map((m) => m.toLowerCase()));
const toRemove = currentMembers.filter((m) => !targetsLower.has(m.toLowerCase()));
const toAdd = targets.filter((t) => !currentLower.has(t.toLowerCase()));
console.log(
`[Plesk updateForwardTargets] ${email} aktuell: [${currentMembers.join(', ')}], ` +
`soll: [${targets.join(', ')}], entfernen: [${toRemove.join(', ')}], hinzufügen: [${toAdd.join(', ')}]`,
);
// Entfernen-Schritt
if (toRemove.length > 0) {
const delParams = [
'--update', email,
'-forwarding-addresses', `del:${toRemove.join(',')}`,
];
const delResult = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST', '/api/v2/cli/mail/call', { params: delParams },
);
console.log('[Plesk updateForwardTargets] del response:', JSON.stringify(delResult, null, 2));
if (delResult.code !== 0 || /error|failed/i.test(delResult.stderr || '')) {
return {
success: false,
error: delResult.stderr?.trim() || delResult.stdout?.trim() || `Plesk del returned code ${delResult.code}`,
};
}
}
// Hinzufügen-Schritt (impliziert -forwarding true, damit Mailgroup
// aktiviert bleibt bzw. wird).
if (toAdd.length > 0) {
const addParams = [
'--update', email,
'-forwarding', 'true',
'-forwarding-addresses', `set:${targets.join(',')}`,
],
});
'-forwarding-addresses', `add:${toAdd.join(',')}`,
];
const addResult = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST', '/api/v2/cli/mail/call', { params: addParams },
);
console.log('[Plesk updateForwardTargets] add response:', JSON.stringify(addResult, null, 2));
if (addResult.code !== 0 || /error|failed/i.test(addResult.stderr || '')) {
return {
success: false,
error: addResult.stderr?.trim() || addResult.stdout?.trim() || `Plesk add returned code ${addResult.code}`,
};
}
}
return {
success: true,
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
exists: boolean;
email?: string;
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
// Plesk hat zwei unabhängige Verteil-Mechanismen, beide können parallel
// aktiv sein. Manuelle/Legacy-Anlagen nutzen oft "Mailgroup" statt
// "Forwarding" unser Sync muss alte Mitglieder dort einsammeln,
// sonst gehen sie beim Umschalten auf Forwarding verloren.
mailgroupActive?: boolean;
mailgroupMembers?: string[];
forwardingActive?: boolean;
forwardingTargets?: string[];
}
export interface EmailOperationResult {
@@ -46,6 +46,13 @@ export interface ProviderExport {
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
contactEmail: string | null;
contactPhone: string | null;
contactFax: string | null;
contactAddress: string | null;
cancellationEmail: string | null;
cancellationFax: string | null;
cancellationAddress: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
contactFax: p.contactFax,
contactAddress: p.contactAddress,
cancellationEmail: p.cancellationEmail,
cancellationFax: p.cancellationFax,
cancellationAddress: p.cancellationAddress,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
create: {
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
contactEmail: p.contactEmail ?? null,
contactPhone: p.contactPhone ?? null,
contactFax: p.contactFax ?? null,
contactAddress: p.contactAddress ?? null,
cancellationEmail: p.cancellationEmail ?? null,
cancellationFax: p.cancellationFax ?? null,
cancellationAddress: p.cancellationAddress ?? null,
isActive: p.isActive ?? true,
},
});
+61 -20
View File
@@ -1,5 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -51,48 +51,89 @@ export async function getProviderById(id: number) {
// Aktuell escapt React das Textnode, also kein direkter XSS aber neue
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(data: T): T {
//
// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch
// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates),
// contactPhone/contactFax/cancellationFax durch sanitizePhoneField
// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit
// 500-Cap (mehrzeilig, normalisierte Newlines).
function stripProviderStrings<T extends object>(data: T): T {
const out: any = { ...data };
if (typeof out.name === 'string') out.name = stripHtml(out.name);
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
}
for (const k of ['contactEmail', 'cancellationEmail'] as const) {
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
if (out[k] === undefined) continue;
const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k];
const value = typeof stripped === 'string' ? stripped.trim() : stripped;
if (value === '' ) { out[k] = null; continue; }
if (!isValidEmail(value)) {
throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`);
}
out[k] = value;
}
const phoneLabels: Record<string, string> = {
contactPhone: 'Kontakt-Telefonnummer',
contactFax: 'Kontakt-Faxnummer',
cancellationFax: 'Kündigungs-Faxnummer',
};
for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) {
if (out[k] === undefined) continue;
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
const v = sanitizePhoneField(out[k], phoneLabels[k]);
out[k] = v === undefined ? null : v;
}
const addressLabels: Record<string, string> = {
contactAddress: 'Kontakt-Postadresse',
cancellationAddress: 'Kündigungs-Postadresse',
};
for (const k of ['contactAddress', 'cancellationAddress'] as const) {
if (out[k] === undefined) continue;
// R89.1/R89.2: validateProviderAddress wirft 400 bei Längen-
// Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate,
// kein silent null-overwrite mehr.
out[k] = validateProviderAddress(out[k], addressLabels[k]);
}
return out;
}
export async function createProvider(data: {
name: string;
interface ProviderWritable {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
}) {
contactEmail?: string | null;
contactPhone?: string | null;
contactFax?: string | null;
contactAddress?: string | null;
cancellationEmail?: string | null;
cancellationFax?: string | null;
cancellationAddress?: string | null;
isActive?: boolean;
}
export async function createProvider(data: ProviderWritable & { name: string }) {
const clean = stripProviderStrings(data);
const portalUrl = assertValidPortalUrl(clean.portalUrl);
return prisma.provider.create({
data: {
...clean,
name: clean.name,
portalUrl,
isActive: true,
},
});
}
export async function updateProvider(
id: number,
data: {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
isActive?: boolean;
}
) {
export async function updateProvider(id: number, data: ProviderWritable) {
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
// damit Prisma nicht den alten Wert hält.
const updateData: typeof data = stripProviderStrings(data);
const updateData: any = stripProviderStrings(data);
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
updateData.portalUrl = validated ?? null;
}
return prisma.provider.update({
where: { id },
+301 -1
View File
@@ -11,6 +11,75 @@ import {
getActiveProviderConfig,
} from './emailProvider/emailProviderService.js';
import { generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiError } from '../utils/apiError.js';
// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces,
// Komma). Wirklich validiert wird vom Provider beim Sync.
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
// Pentest 71.1 (MEDIUM, 2026-06-08): RFC-reservierte und private/
// On-Prem-TLDs. Eine Weiterleitung an `attacker@plesk.internal` würde
// am Provider DNS-Lookups gegen internes Netz auslösen oder bei mDNS-
// Setup an einen lokalen Mailserver gehen. Wir blocken sie hart.
const BLOCKED_TLDS = new Set([
'local', 'internal', 'corp', 'lan', 'home', 'private',
'invalid', 'test', 'localhost', 'example',
'intranet', 'localdomain', 'arpa',
]);
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '');
} catch {
return [];
}
}
export function serializeAdditionalForwards(list: string[]): string | null {
const cleaned = list.map((s) => s.trim()).filter((s) => s !== '');
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
}
/**
* Liefert eine normalisierte Form der Adresse für den Dedup-Vergleich:
* lowercase + Plus-Tag aus dem Local-Part rausgestrippt.
* `billing+pentest@test.de` und `billing@test.de` haben so denselben
* Schlüssel und treffen sich beim Dedup. Pentest 71.2.
*/
export function canonicalEmailKey(email: string): string {
const trimmed = email.trim().toLowerCase();
const at = trimmed.lastIndexOf('@');
if (at < 1) return trimmed;
const localPart = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
const plus = localPart.indexOf('+');
const cleanedLocal = plus === -1 ? localPart : localPart.slice(0, plus);
return `${cleanedLocal}@${domain}`;
}
export function assertValidForwardingEmail(value: unknown): string {
if (typeof value !== 'string') {
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
}
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > 254) {
throw new ApiError(400, 'Weiterleitungs-E-Mail-Adresse ist leer oder zu lang');
}
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
}
// 71.1: TLD aus dem Domain-Part rausziehen und gegen die Blocklist
// halten. Domain liegt nach dem letzten @, TLD nach dem letzten Punkt.
const domain = trimmed.slice(trimmed.lastIndexOf('@') + 1).toLowerCase();
const tld = domain.slice(domain.lastIndexOf('.') + 1);
if (BLOCKED_TLDS.has(tld)) {
throw new ApiError(400, `Top-Level-Domain "${tld}" ist nicht erlaubt (reservierte/private TLD).`);
}
return trimmed.toLowerCase();
}
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
const where: Record<string, unknown> = { customerId };
@@ -83,6 +152,27 @@ export interface CreateEmailData {
export async function createEmail(data: CreateEmailData) {
const { provisionAtProvider, createMailbox, ...emailData } = data;
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
// der Praxis nie semantischen Unterschied macht und der Provider eh
// einheitlich lowercased.
const normalized = data.email.trim().toLowerCase();
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: data.customerId,
email: { equals: normalized },
},
select: { id: true, isActive: true },
});
if (conflict) {
const hint = conflict.isActive
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
throw new ApiError(409, hint);
}
// Wert in DB ist eh schon lowercase wir setzen es einheitlich.
emailData.email = normalized;
// Falls beim Provider anlegen gewünscht
if (provisionAtProvider) {
// Kunde laden für Weiterleitung
@@ -153,6 +243,34 @@ export async function updateEmail(
isActive?: boolean;
}
) {
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
// anlegen (Umweg um den Create-Check).
if (typeof data.email === 'string' && data.email.trim() !== '') {
const normalized = data.email.trim().toLowerCase();
const current = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { customerId: true, email: true },
});
if (!current) {
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
}
if (normalized !== current.email.toLowerCase()) {
const conflict = await prisma.stressfreiEmail.findFirst({
where: {
customerId: current.customerId,
email: { equals: normalized },
NOT: { id },
},
select: { id: true },
});
if (conflict) {
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
}
}
data.email = normalized;
}
return prisma.stressfreiEmail.update({
where: { id },
data,
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
return prisma.stressfreiEmail.delete({ where: { id } });
}
/**
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
* Liste das Sub-Modal arbeitet auf Snapshot-Basis.
*
* Pentest 71.2: Dedup über `canonicalEmailKey` (Plus-Tags strippen),
* damit `billing+tag@x.de` und `billing@x.de` als gleiches Ziel
* erkannt werden auch im Vergleich zur Stamm-E-Mail des Kunden.
*
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
* damit Plesk und DB nicht auseinanderlaufen.
*
* Pentest 81.1: Self-Forward wird hart abgelehnt würde sonst am
* Provider einen Mail-Loop erzeugen (Stressfrei-Adresse leitet auf
* sich selbst um → unendliche Weiterleitung).
*/
export async function setAdditionalForwards(
id: number,
emails: string[],
): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> {
// Kunden-Stamm-Mail + eigene Email holen für Dedup gegen die fest
// gesetzten Ziele bzw. die Stressfrei-Adresse selbst.
const meta = await prisma.stressfreiEmail.findUnique({
where: { id },
select: {
email: true,
additionalForwardingEmails: true,
customer: { select: { email: true } },
},
});
if (!meta) {
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
}
const previousRaw = meta.additionalForwardingEmails;
const customerEmailKey = meta.customer?.email
? canonicalEmailKey(meta.customer.email)
: null;
const selfKey = canonicalEmailKey(meta.email);
// Input normalisieren + Duplikate raus.
const seen = new Set<string>();
if (customerEmailKey) seen.add(customerEmailKey);
const cleaned: string[] = [];
for (const raw of emails) {
const ok = assertValidForwardingEmail(raw);
const key = canonicalEmailKey(ok);
// 81.1: Eintrag, der auf die Adresse selbst zeigt, würde einen
// Mail-Loop am Provider erzeugen. Hart ablehnen mit klarer
// Fehlermeldung, statt silent zu droppen der User soll merken,
// dass sein Eintrag bewusst nicht akzeptiert wurde.
if (key === selfKey) {
throw new ApiError(
400,
`"${ok}" zeigt auf die Adresse selbst Mail-Loop. Bitte eine andere Weiterleitungsadresse wählen.`,
);
}
if (!seen.has(key)) {
seen.add(key);
cleaned.push(ok);
}
}
const nextRaw = serializeAdditionalForwards(cleaned);
await prisma.stressfreiEmail.update({
where: { id },
data: { additionalForwardingEmails: nextRaw },
});
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
// mit der alten Liste weiter. autoImport=false, weil unsere DB-Liste
// hier die explizite User-Intent ist kein Plesk-Member-Auto-Pull,
// sonst landen gerade entfernte Adressen zurück in der Liste.
const syncResult = await syncForwardingForEmail(id, { autoImportPleskMembers: false });
// 71.4: Rollback wenn Plesk den Sync abgelehnt hat. DB darf nicht
// den optimistischen Stand zeigen, wenn der Provider noch auf dem
// alten Stand ist.
if (!syncResult.success && previousRaw !== nextRaw) {
await prisma.stressfreiEmail.update({
where: { id },
data: { additionalForwardingEmails: previousRaw },
}).catch((rollbackErr) => {
console.error(
'[setAdditionalForwards] Rollback nach Provider-Fail fehlgeschlagen:',
rollbackErr,
);
});
}
return syncResult;
}
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
@@ -298,6 +508,7 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
export async function syncForwardingForEmail(
id: number,
options: { autoImportPleskMembers?: boolean } = {},
): Promise<{
success: boolean;
forwardTargets?: string[];
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
passwordReset?: boolean;
error?: string;
}> {
// Auto-Import übernimmt unbekannte Plesk-Members in unsere DB. Macht
// beim Sync-Button Sinn (Bestands-Migration), aber NICHT beim
// User-getriggerten Add/Remove dort ist die DB-Liste die Wahrheit.
// Sonst kreisen entfernte Adressen zurück in die Liste:
// 1. User entfernt c → DB=[a,b], Plesk=[a,b,c]
// 2. Auto-Import: "c ist in Plesk aber nicht in DB → in DB schreiben"
// 3. → DB=[a,b,c], Diff sagt nichts zu löschen, Plesk bleibt [a,b,c].
const autoImport = options.autoImportPleskMembers ?? true;
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: {
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
isProvisioned: true,
hasMailbox: true,
emailPasswordEncrypted: true,
additionalForwardingEmails: true,
},
});
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
// Plesk nicht mit Wiederholungen die Liste aufbläht. Pentest 71.2:
// Vergleich über `canonicalEmailKey`, damit Plus-Tags nicht doppelt
// zustellen.
const seenKeys = new Set(forwardTargets.map(canonicalEmailKey));
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
const key = canonicalEmailKey(extra);
if (!seenKeys.has(key)) {
seenKeys.add(key);
forwardTargets.push(extra);
}
}
const localPart = stressfreiEmail.email.split('@')[0];
// 1) Forwards neu setzen.
// 0) Auto-Migration: Plesk hat zwei Verteil-Mechanismen (Mailgroup +
// Forwarding). Alt-Anlagen liefen oft via Mailgroup unser Sync
// schreibt aber nur in die Forwarding-Liste, daher landeten neue
// Adressen nirgendwo. Hier holen wir die aktuellen Mailgroup-Members
// ab und ziehen alle, die wir nicht schon kennen, in unsere
// additionalForwardingEmails-Liste rein. Der nachfolgende Plesk-Call
// deaktiviert dann die Mailgroup und schreibt die volle Liste als
// Forwarding. Verlustfrei kein Empfänger fällt raus.
// Pentest 83.2: Self-Forward auch beim Import blocken. Die
// Stressfrei-Adresse selbst darf nicht aus Plesk in unsere DB
// landen sonst läuft sie nach dem Mailgroup→Forwarding-Umschalten
// als Forwarding-Target auf sich selbst (Mail-Loop).
seenKeys.add(canonicalEmailKey(stressfreiEmail.email));
if (autoImport) {
try {
const pleskState = await checkEmailExists(localPart);
const existingMembers = [
...(pleskState.mailgroupMembers ?? []),
...(pleskState.forwardingTargets ?? []),
];
const newImports: string[] = [];
for (const member of existingMembers) {
// Pentest 83.1: importierte Adressen aus Plesk müssen denselben
// Filter passieren wie User-Eingaben (TLD-Blocklist, Format).
// Sonst rutschen reservierte TLDs wie `.internal` ohne Check
// in unsere DB, falls ein Plesk-Admin sie dort manuell gepflegt
// hat. Ungültige werden silent gedroppt Log informiert.
let validated: string;
try {
validated = assertValidForwardingEmail(member);
} catch (validationErr) {
const reason = validationErr instanceof Error ? validationErr.message : 'unbekannt';
console.debug(
`[syncForwardingForEmail] Plesk-Member "${member}" verworfen: ${reason}`,
);
continue;
}
const key = canonicalEmailKey(validated);
if (!seenKeys.has(key)) {
seenKeys.add(key);
forwardTargets.push(validated);
newImports.push(validated);
}
}
if (newImports.length > 0) {
const mergedAdditional = [
...parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails),
...newImports,
];
await prisma.stressfreiEmail.update({
where: { id },
data: { additionalForwardingEmails: serializeAdditionalForwards(mergedAdditional) },
});
// Pentest 83.3: PII-Logs auf debug-Level statt log-Level.
console.debug(
`[syncForwardingForEmail] Importiert aus Plesk-Mailgroup für ${stressfreiEmail.email}:`,
newImports,
);
}
} catch (importErr) {
// Nicht hart fehlschlagen im schlimmsten Fall fehlen ein paar
// alte Empfänger, aber der eigentliche Sync soll trotzdem laufen.
console.error('[syncForwardingForEmail] Mailgroup-Import fehlgeschlagen:', importErr);
}
}
// 1) Forwards neu setzen (deaktiviert intern Mailgroup).
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
if (!forwardResult.success) {
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
+153
View File
@@ -75,6 +75,7 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
'tariffName',
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
'portalUsername',
@@ -342,6 +343,158 @@ export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): strin
return trimmed;
}
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
// in PDF-/Mail-Export potentiell aktiv werden könnten.
//
// Whitelist orientiert sich am Vorschlag des Pentesters
// `^[\w\-\/\s\.]*$` Whitespace ist hier bewusst NUR ein literales
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
// Limit der DB-Spalte.
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
'customerNumberAtProvider',
'contractNumberAtProvider',
'orderNumberAtSalesPlatform',
'customerNumberAtSalesPlatform',
'contractNumberAtSalesPlatform',
]);
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
export function isContractIdentifierField(key: string): boolean {
return CONTRACT_IDENTIFIER_FIELDS.has(key);
}
export function validateContractIdentifier(
raw: unknown,
fieldLabel: string,
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
);
}
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
);
}
return trimmed;
}
// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
// nachweisbare Effekte:
// - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
// Header-Injection-Vektor sobald der Wert in Mail-Templates
// oder PDF-Footers landet.
// - `<script>alert(1)</script>@x.de` lief durch stripHtml →
// stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
// - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
//
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
// `portalUsername` ist im Manual-Modus nicht zwingend eine
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
// Der Stressfrei-Modus hängt eh an einer schon validierten
// Email-Stammdate (assertValidForwardingEmail).
//
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
const PORTAL_USERNAME_MAX_LEN = 100;
export function validatePortalUsername(
raw: unknown,
fieldLabel = 'portalUsername',
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
const trimmed = raw.trim();
if (trimmed === '') return null;
if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
);
}
if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
);
}
return trimmed;
}
// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
// stripHtml lief vor dem Length-Check derselbe Fehler wie R87:
// `<script>…</script>` reduziert auf leeren String → null in der
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
//
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
// - CRLF → LF normalisieren
const PROVIDER_ADDRESS_MAX_LEN = 500;
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere inkl. Tab (`\x09`)
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
// und nichts, was ein Mensch je tippt.
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;
export function validateProviderAddress(
raw: unknown,
fieldLabel: string,
): string | null {
if (raw == null) return null;
if (typeof raw !== 'string') {
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
}
// CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen ein Editor der
// immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
// Zeichen gegen das 500er-Cap zählen.
const normalized = raw.replace(/\r\n?/g, '\n');
if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
throw new ApiError(
400,
`${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
);
}
if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
throw new ApiError(
400,
`${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
);
}
const trimmed = normalized.trim();
return trimmed === '' ? null : trimmed;
}
const NOTES_DEFAULT_MAX = 2000;
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
if (raw == null) return null;
+181
View File
@@ -473,6 +473,187 @@ Vor jedem Launch mit echten Tokens probieren.
---
## 📝 Bewusste Akzeptanz URL-encoded Route-Parameter
**Finding aus Pentest-Runde 85 (INFO, kein Security-Impact):**
`/api/stressfrei-emails/%31/...` (URL-encoded `1`) liefert dieselbe
Antwort wie `/api/stressfrei-emails/1/...` die `requireIdParam`-
Validierung mit `/^\d+$/` sieht die schon dekodierte Form, weil
Express URL-Parameter **vor** dem Routing dekodiert.
**Wir akzeptieren das als by-design:**
1. RFC 3986 fordert genau dieses Verhalten: prozentual-codierte und
roh-Form derselben Zeichen müssen semantisch identisch behandelt
werden. `/1` und `/%31` ist HTTP-konform äquivalent.
2. Ein nachträglicher Strict-Check auf `req.url` (roh) würde
legitime Clients brechen Browser-Bookmarks mit URL-Encoding,
curl-Calls mit `--data-urlencode`, Proxy-Tools die URL-encoden,
Mobile-Clients mit kanonischer URL-Normalisierung.
3. Der Pentester selbst bestätigt: **kein Security-Impact** die ID
landet nach Dekodierung als gültige Zahl bei der gleichen
Auth-/IDOR-Logik, die auch für die rohe Form greift. Insbesondere
`canAccessStressfreiEmail` läuft identisch.
4. Validierungs- und Access-Control-Kette ist nach der Dekodierung
identisch dicht Auth, Ownership, Rate-Limit, alles greift gleich.
**Code-Notiz:** kein Patch nötig. Diese Markdown-Sektion ist die
einzige Dokumentation, damit das in zukünftigen Pentest-Runden nicht
erneut als „offenes Finding" auftaucht.
---
## 🔒 Runde 86 Vertrags-Identifier-Validierung
**Findings (beide LOW):**
- **R86.1**: Strings >999 Zeichen in `orderNumberAtSalesPlatform` / den
vier verwandten Sales-/Provider-Nummern-Feldern endeten mit
generischem 500 (DB-Overflow `VARCHAR(191)`) statt sauberem 400.
- **R86.2**: Attribut-Injection-Payload `foo" onerror="alert(1)`
(kein umschließender Tag) überlebte `stripHtml`. React escaped
Attribute, aber sobald der Wert in PDF-/Mail-/CSV-Export fließt,
ist es potentiell aktiv.
**Fix:** `validateContractIdentifier(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Max-Länge 100 Zeichen (deutlich unter VARCHAR(191)).
- Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$` Buchstaben, Ziffern,
Punkt, Bindestrich, Schrägstrich, Unterstrich und literales
Leerzeichen. Bewusst NICHT `\s` (kein CRLF/Tab → kein
Header-Injection-Vektor in CSV-/Mail-Exporten).
- Bei Verstoß: `ApiError(400, …)` mit konkreter Fehlermeldung
statt 500.
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
fünf Identifier-Felder (`customerNumberAtProvider`,
`contractNumberAtProvider`, `orderNumberAtSalesPlatform`,
`customerNumberAtSalesPlatform`, `contractNumberAtSalesPlatform`)
bei jedem Create/Update.
- Frontend: `maxLength={100}` als zusätzliche UX-Schicht im
ContractForm Server-seitige Validierung bleibt die einzige
Wahrheit, das HTML-Attribut spart nur den unnötigen Round-Trip.
---
## 🔒 Runde 87 Whitelist vor Sanitizer (silent-mutation-Schutz)
**Finding (LOW): Sanitizer-Order maskiert Tag-Verstöße**
Im ursprünglichen R86-Fix lief `stripHtml(body)` **vor**
`validateContractIdentifier`. Das hatte einen subtilen Bypass:
| Payload | Status | Tatsächlich gespeichert |
|--------------------------------------|------------|-------------------------|
| `<b>bold</b>` | 200 OK | `"bold"` (silent strip) |
| `EVN<b>2024</b>` | 200 OK | `"EVN2024"` |
| `<script>alert(1)</script>` | **200 OK** | `null` **vorherigen Wert überschrieben** |
| `foo<bar>baz` | 200 OK | `"foobarbaz"` |
Kein direkter XSS-Vektor (React + DB-Whitelist greifen weiterhin),
aber zwei reale UX-/Datenintegritäts-Risiken:
1. Admin tippt `VG<2024>001`, bekommt 200 zurück, gespeichert ist
`VG2024001` ohne Hinweis auf die Mutation.
2. Werte die komplett aus Tags bestehen (`<script>…</script>`)
werden vom Sanitizer auf den leeren String reduziert →
`null` in der DB → **vorheriger Wert wird stillschweigend
gelöscht**.
**Fix:** Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht `validateContractIdentifier` läuft jetzt **direkt
gegen den Raw-Input**, ohne dass `stripHtml` ihn vorher
glättet. Die strikte Whitelist
`^[A-Za-z0-9_\-/. ]{0,100}$` lehnt sowieso alles ab, was
`stripHtml` normalerweise abgefangen hätte (Tags, Schemes,
Zero-Width-Chars, Homoglyphe, Percent-Encoding) Defense-in-
Depth bleibt unverändert, nur jetzt ehrlich (400 statt silent-200).
Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backend/src/controllers/contract.controller.ts)
`sanitizeContractBody`.
---
## 🔒 Runde 89 Provider-Adressfelder härten
**Findings (R89.1 MEDIUM + R89.2 LOW):**
Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden
`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)`
geleitet. Zwei Probleme:
- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 501+ Zeichen
wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert.
- **R89.2**: stripHtml lief vor dem Length-Check derselbe Bug wie R87.
`<script>…</script>` → leerer String → `null` in der DB → vorheriger
Wert ohne Fehlermeldung überschrieben.
**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Validiert den Raw-Input direkt kein stripHtml davor.
- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung.
- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` erlaubt ist
nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle
anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor
für CSV-/Mail-Exporte und in einer Postadresse nie legitim.
- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor
mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt.
- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen).
Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die
übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch
`isValidEmail` / `sanitizePhoneField` die hat der Pentester explizit
als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt).
**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`).
Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped
korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil
einer mehrzeiligen Postadresse.
---
## 🔒 Runde 95 Portal-Username-Validierung
**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**
`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
Validierung. Drei nachweisbare Effekte:
- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
gespeichert → Header-Injection-Vektor sobald der Wert in
Mail-Templates oder PDF-Footers landet.
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
`stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
auf neuem Feld).
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
statt sauberem 400.
**Fix:** `validatePortalUsername(raw, fieldLabel)` in
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
→ R95.1 und R95.3 ohne extra Check.
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
- Raw-Input direkt validiert (kein `stripHtml` davor) gleicher
R87-Pattern wie bei Contract-Identifier und Provider-Address.
- Eingehängt in `sanitizeContractBody` als eigener Branch.
**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**
Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
Modus hängt sowieso an einer schon validierten Email-Stammdate
(`assertValidForwardingEmail`).
---
## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist:
+414
View File
@@ -97,6 +97,420 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [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): `<script>…</script>@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 `<script>…</script>`
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 `<textarea rows={3}>` mit `maxLength={500}`.
- Backend-Validierung: contactEmail/cancellationEmail laufen durch
`isValidEmail` (Header-Injection-Schutz für Mail-Templates),
contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
(kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
500-Cap.
- Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
Felder beim Backup/Restore verloren.
- [x] **🔒 Pentest R87 Whitelist vor Sanitizer (silent-mutation-Schutz)**
- R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
`<script>…</script>` reduzierte auf leeren String → `null` in
der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
- Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
umgedreht `validateContractIdentifier` läuft jetzt direkt
gegen den Raw-Input. 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).
- Single-Line-Patch in `contract.controller.ts`, Doku in
`SECURITY-HARDENING.md § Runde 87`.
- [x] **🔒 Pentest R86 Vertrags-Identifier härten**
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
- R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
(`foo" onerror=…`) überlebte `stripHtml` kein Risiko in der React-
UI, aber relevant für PDF/Mail/CSV-Export.
- Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
fünf Identifier-Felder bei Create/Update. ContractForm bekommt
`maxLength={100}` als UX-Schicht. Doku in
`docs/SECURITY-HARDENING.md` § Runde 86.
- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
- Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
(`VARCHAR(191) NULL`), Migration
`20260619100000_contract_order_number_at_sales_platform` mit
`IF NOT EXISTS`.
- Im ContractForm direkt **vor** der Kundennummer der
Vertriebsplattform angeordnet (Wunsch des Users).
ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
den anderen beiden Sales-Platform-Feldern.
- Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
mitgezogen, damit das neue Feld die gleichen Garantien wie
Kunden-/Vertragsnummer bekommt.
- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
Plesk aber nicht in DB" → schreibt `c` zurück in
`additionalForwardingEmails` → Diff sagt nichts zu entfernen.
- Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
explizite Intent (Import schädlich).
- Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
mit Default `true`. `setAdditionalForwards` ruft mit
`false` auf → entfernte Adressen verschwinden jetzt sauber bei
Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
alte Bestands-Members).
- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
- Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
akzeptiert ausschließlich `add:` und `del:` unser `set:` wurde
von Plesk silent verworfen. Außerdem gibt es keine separate
`-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
zeigt, ist genau das, was `-forwarding true` in der CLI setzt
(doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
den Phantom-Parameter und triggerte `Unrecognized option`.
- `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
`add:<neu>` in zwei separaten CLI-Calls. Idempotent.
Case-insensitive `Bruns.Gerhard``bruns.gerhard`.
- Phantom-`-mailgroup`-Parameter entfernt.
- Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.
- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
- **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
`assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
DB gewandert. Fix: jeder importierte Member läuft durch
`assertValidForwardingEmail`; ungültige werden silent gedroppt
und auf `console.debug`-Level geloggt.
- **83.2 LOW:** Self-Forward-Schutz (81.1) lief 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.add(canonicalEmailKey(stressfreiEmail.email))` vor
der Import-Schleife.
- **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
`console.log` auf Default-Level).
- Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
abgelehnt, Customer-Stamm-Mail + Default deduped.
- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
- Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
`Group member(s):`) und **Forwarding** (`Forward request:`). Unser
Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
→ unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
wurden (Forwarding-Modus von Anfang an).
- `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
`forwardingActive` + `forwardingTargets` erweitert.
- `pleskProvider.emailExists` parst alle vier Felder aus dem
`--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
`Forward request: ...`).
- `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
`-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
und nur noch Forwarding aktiv ist.
- `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
`additionalForwardingEmails`-Liste **importiert** (canonical-Key-
Dedup). Verlustfrei kein bestehender Empfänger fällt beim
Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
aber der eigentliche Sync läuft trotzdem.
- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
`max.mustermann@stressfrei-wechseln.net`) als zusätzliches
Weiterleitungsziel eintragen auch Plus-Varianten davon. Plesk
leitet auf sich selbst um → Mail-Loop.
- Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
`meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
`ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
- Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
Validierung im Sub-Modal mit identischem `canonicalize`-Helper
(Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
auch der Create-Modus (vor dem Persistieren) den Check fahren
kann. Spart einen Roundtrip + zeigt sofort eine sprechende
Meldung „… zeigt auf die Adresse selbst Mail-Loop".
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
`4.0` und Exp-Notation `4e1`.
- Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
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.
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
- Service: Vor `prisma.create` jetzt `findFirst` auf
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
(vorher pauschal 400) → 409 kommt sauber durch.
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
nur ins Leere lief.
- [x] **🔒 Pentest 71.171.4: Härtung der Zusatz-Weiterleitungen**
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
`example`, `intranet`, `localdomain`, `arpa`) werden in
`assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
- **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
dasselbe Ziel erkannt auch im Vergleich zur Kunden-Stamm-Mail
und im sync-Pfad gegen `config.defaultForwardEmail`.
- **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
umgestellt (auch über das gemeldete Finding hinaus).
- **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
Provider-Sync-Fehler zurück, damit DB und Plesk nicht
auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
wird vor dem Update gemerkt und bei Fail wieder eingespielt.
- Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
Strip funktioniert (auch mit Multi-Plus + Casing).
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
läuft direkt nach `createEmail` mit der vollen Liste.
- Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
→ lokaler State (Create). Counter-Badge am Button zeigt die
Anzahl Adressen.
- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
- Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
mit `IF NOT EXISTS`.
- `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
`customer.email` und `config.defaultForwardEmail`).
- Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
mit Body `{ emails: string[] }` ersetzt die Liste und syncht
direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
Regex, Audit-Log.
- Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
(Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
mit Liste + Add/Remove. Jede Änderung geht sofort live.
- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
- Vier identische Vorkommen desselben Anti-Patterns wie beim
AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
Tastendruck setzte den State zurück.
- Fix in allen vier Modals (MeterModal, BankCardModal,
IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
`[<entity>?.id]`-Dependency umgezogen.
- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
- Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
auch ohne Re-Encode auf 5-10 MB pro Bild.
- Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
PDF-Embed laufen alle auf dem skalierten Bild.
- Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FileText, Loader2, ExternalLink } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
import { viewUrl } from '../../utils/fileUrl';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
currentAttachments: EmailAttachment[];
onAttach: (added: EmailAttachment[]) => void;
}
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
export default function AttachContractDocumentsModal({
isOpen,
onClose,
contractId,
currentAttachments,
onAttach,
}: Props) {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract-documents', contractId],
queryFn: () => contractApi.getDocuments(contractId),
enabled: isOpen,
});
const documents = data?.data || [];
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleClose = () => {
if (busy) return; // Kein Abbruch während Download läuft
setSelected(new Set());
onClose();
};
const handleAttach = async () => {
if (selected.size === 0) {
handleClose();
return;
}
setBusy(true);
const docsToFetch = documents.filter((d) => selected.has(d.id));
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
try {
for (const doc of docsToFetch) {
try {
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
{ duration: 6000 },
);
break;
}
newAttachments.push(att);
runningSize += approxBytes;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
}
}
if (newAttachments.length > 0) {
onAttach(newAttachments);
toast.success(
newAttachments.length === 1
? '1 Dokument angehängt'
: `${newAttachments.length} Dokumente angehängt`,
);
}
setSelected(new Set());
onClose();
} finally {
setBusy(false);
}
};
// Nach documentType gruppieren für übersichtliche Darstellung
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
const key = doc.documentType || 'Sonstiges';
if (!acc[key]) acc[key] = [];
acc[key].push(doc);
return acc;
}, {});
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Vertragsdokumente anhängen"
size="lg"
>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Dokumente werden geladen
</div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<FileText className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
</div>
) : (
<div className="space-y-4 max-h-96 overflow-y-auto">
{Object.entries(grouped).map(([type, docs]) => (
<div key={type}>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
{type}
</div>
<div className="space-y-1">
{docs.map((doc) => (
<div
key={doc.id}
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
>
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
<input
type="checkbox"
checked={selected.has(doc.id)}
onChange={() => toggle(doc.id)}
disabled={busy}
className="mt-0.5 rounded"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="truncate">{doc.originalName}</span>
</div>
{doc.notes && (
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
{doc.notes}
</div>
)}
</div>
</label>
<a
href={viewUrl(doc.documentPath)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
title="Dokument in neuem Tab öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Vorschau</span>
</a>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleAttach}
disabled={busy || selected.size === 0}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Anhängen
</>
) : (
'Anhängen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}
@@ -1,10 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import { Send, Paperclip, X, FileText, FilePlus, UserPlus } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
import { useMutation } from '@tanstack/react-query';
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
import InsertCustomerDataModal from './InsertCustomerDataModal';
interface ComposeEmailModalProps {
isOpen: boolean;
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [error, setError] = useState<string | null>(null);
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Formular bei Modal-Öffnung initialisieren
@@ -308,7 +312,8 @@ export default function ComposeEmailModal({
className="hidden"
/>
{/* Anhang hinzufügen Button */}
{/* Anhang-/Daten-Buttons */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
@@ -317,6 +322,29 @@ export default function ComposeEmailModal({
<Paperclip className="w-4 h-4 mr-2" />
Datei anhängen
</button>
{contractId && (
<>
<button
type="button"
onClick={() => setShowAttachDocsModal(true)}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Bereits am Vertrag hinterlegte Dokumente direkt anhängen"
>
<FilePlus className="w-4 h-4 mr-2" />
Vertragsdokumente
</button>
<button
type="button"
onClick={() => setShowInsertDataModal(true)}
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title="Kunden- und Vertragsdaten in die Nachricht einfügen, optional Ausweis/Bankkarte als PDF anhängen"
>
<UserPlus className="w-4 h-4 mr-2" />
Kundendaten einfügen
</button>
</>
)}
</div>
{/* Anhang-Liste */}
{attachments.length > 0 && (
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
</Button>
</div>
</div>
{/* Sub-Modal: Vertragsdokumente anhängen */}
{contractId && (
<AttachContractDocumentsModal
isOpen={showAttachDocsModal}
onClose={() => setShowAttachDocsModal(false)}
contractId={contractId}
currentAttachments={attachments}
onAttach={(added) => setAttachments((prev) => [...prev, ...added])}
/>
)}
{/* Sub-Modal: Kundendaten einfügen */}
{contractId && (
<InsertCustomerDataModal
isOpen={showInsertDataModal}
onClose={() => setShowInsertDataModal(false)}
contractId={contractId}
senderEmail={account.email}
currentBody={body}
currentAttachments={attachments}
onResult={(newBody, addedAtt) => {
setBody(newBody);
if (addedAtt.length > 0) {
setAttachments((prev) => [...prev, ...addedAtt]);
}
}}
/>
)}
</Modal>
);
}
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/
import { useAuth } from '../../context/AuthContext';
import Button from '../ui/Button';
import Card from '../ui/Card';
import CopyButton from '../ui/CopyButton';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
import TrashEmailList from './TrashEmailList';
@@ -49,28 +50,44 @@ export default function ContractEmailsSection({
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
// E-Mails für den Vertrag laden (nach Ordner gefiltert, nicht für TRASH)
// E-Mails für den Vertrag laden (nach Ordner UND Postfach gefiltert).
// Bug 2026-06-21: vorher gingen Mails aus allen Postfächern in den
// gewählten Vertrags-Ordner obwohl der User ein bestimmtes Postfach
// ausgewählt hatte. selectedAccountId muss in queryKey + queryFn.
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'contract', contractId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
enabled: selectedFolder !== 'TRASH',
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, {
folder: selectedFolder as 'INBOX' | 'SENT',
accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
});
const emails = emailsData?.data || [];
// Papierkorb-E-Mails laden (für den ganzen Kunden, da Trash nicht vertragsgebunden)
// Papierkorb-E-Mails laden jetzt strikt: nur das aktuell ausgewählte
// Postfach UND nur dem Vertrag zugeordnete Mails. Wenn man also den
// Vertrags-Papierkorb öffnet, sieht man nicht mehr alle gelöschten
// E-Mails des Kunden, sondern wirklich nur die, die diesem Vertrag
// aus diesem Postfach zugeordnet waren.
const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId],
queryFn: () => cachedEmailApi.getTrash(customerId),
enabled: selectedFolder === 'TRASH' && canAccessTrash,
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
queryFn: () => cachedEmailApi.getTrash(customerId, {
accountId: selectedAccountId ?? undefined,
contractId,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
});
const trashEmails = trashData?.data || [];
// Ordner-Anzahlen für Badges (Vertrag)
// Ordner-Anzahlen für Badges (Vertrag + Postfach). Badge und Liste
// müssen mit derselben Filter-Kombination laufen, sonst zeigt der
// Badge eine andere Zahl als die sichtbare Liste.
const { data: folderCountsData } = useQuery({
queryKey: ['contract-folder-counts', contractId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
enabled: !!selectedAccountId,
});
const folderCounts = folderCountsData?.data || {
@@ -78,16 +95,6 @@ export default function ContractEmailsSection({
inboxUnread: 0,
sent: 0,
sentUnread: 0,
};
// Ordner-Anzahlen für das Konto (für Trash-Badge)
const { data: accountFolderCountsData } = useQuery({
queryKey: ['folder-counts', selectedAccountId],
queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!),
enabled: !!selectedAccountId && canAccessTrash,
});
const accountFolderCounts = accountFolderCountsData?.data || {
trash: 0,
trashUnread: 0,
};
@@ -358,11 +365,23 @@ export default function ContractEmailsSection({
</option>
))}
</select>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Inbox className="w-4 h-4 text-gray-500" />
<span>{selectedAccount?.email}</span>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
)}
@@ -429,18 +448,18 @@ export default function ContractEmailsSection({
>
<Trash2 className="w-4 h-4" />
Papierkorb
{accountFolderCounts.trash > 0 && (
{folderCounts.trash > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
accountFolderCounts.trashUnread > 0
folderCounts.trashUnread > 0
? 'bg-red-100 text-red-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
>
{accountFolderCounts.trashUnread > 0
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
: accountFolderCounts.trash}
{folderCounts.trashUnread > 0
? `${folderCounts.trashUnread}/${folderCounts.trash}`
: folderCounts.trash}
</span>
)}
</button>
@@ -470,9 +489,13 @@ export default function ContractEmailsSection({
)}
</div>
) : (
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
{/* Email List */}
<div className="w-1/3 border-r border-gray-200 overflow-auto">
<div
className="flex -mx-6 -mb-6"
style={{ height: '600px' }}
>
{/* Email List scrollt intern, damit die Vertrags-Seite nicht
elendig lang wird. */}
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
{selectedFolder === 'TRASH' ? (
<TrashEmailList
emails={trashEmails}
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
import { useAuth } from '../../context/AuthContext';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import Button from '../ui/Button';
import CopyButton from '../ui/CopyButton';
import EmailList from './EmailList';
import EmailDetail from './EmailDetail';
import ComposeEmailModal from './ComposeEmailModal';
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const emails = emailsData?.data || [];
// Papierkorb-E-Mails laden
// Papierkorb-E-Mails laden jetzt strikt pro Postfach.
// Bug 2026-06-21: vorher kamen alle gelöschten E-Mails des Kunden
// raus, egal welches Postfach selektiert war. selectedAccountId muss
// in queryKey + queryFn, sonst greift React-Query-Cache bei Wechsel
// nicht und der Folder-Count aus folderCountsData liefe auseinander.
const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId],
queryFn: () => cachedEmailApi.getTrash(customerId),
enabled: selectedFolder === 'TRASH' && canAccessTrash,
queryKey: ['emails', 'trash', customerId, selectedAccountId],
queryFn: () => cachedEmailApi.getTrash(customerId, {
accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
});
const trashEmails = trashData?.data || [];
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
};
return (
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
// Bounded auf Viewport-Höhe sonst ignoriert h-full ohnehin den
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
// ganze Seite scrollt statt nur die E-Mail-Liste.
<div
className="flex flex-col"
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
>
{/* Header */}
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
{/* Account Selector */}
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
</option>
))}
</select>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
size="md"
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
) : (
<div className="flex items-center gap-3 text-sm text-gray-600">
<Inbox className="w-5 h-5 text-gray-500" />
<span>{selectedAccount?.email}</span>
{selectedAccount?.email && (
<CopyButton
value={selectedAccount.email}
size="md"
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
/>
)}
</div>
)}
@@ -0,0 +1,613 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { contractApi, EmailAttachment } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import {
bankCardAttachmentName,
identityDocAttachmentName,
serverFileToAttachment,
totalAttachmentBytes,
} from './composeAttachmentHelpers';
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
interface Props {
isOpen: boolean;
onClose: () => void;
contractId: number;
/**
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
* E-Mail angeboten User-Wunsch 2026-06-21: bei Kundendaten wählen,
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
* eingefügt wird.
*/
senderEmail: string;
currentBody: string;
currentAttachments: EmailAttachment[];
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
}
type EmailChoice = 'master' | 'sender' | 'none';
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
type SectionKey =
| 'customer'
| 'deliveryAddress'
| 'billingAddress'
| 'contract';
export default function InsertCustomerDataModal({
isOpen,
onClose,
contractId,
senderEmail,
currentBody,
currentAttachments,
onResult,
}: Props) {
const { data, isLoading } = useQuery({
queryKey: ['contract', contractId, 'for-insert-data'],
queryFn: () => contractApi.getById(contractId),
enabled: isOpen,
});
const contract = data?.data;
const customer = contract?.customer;
const deliveryAddress = contract?.address;
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
// von der Lieferadresse unterscheidet sonst doppelt im Text.
const billingAddress = useMemo(() => {
if (!contract?.billingAddress) return undefined;
if (!deliveryAddress) return contract.billingAddress;
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
}, [contract?.billingAddress, deliveryAddress]);
const bankCard = contract?.bankCard;
const identityDocument = contract?.identityDocument;
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
// entscheiden, was vertraulich verschickt wird).
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
customer: true,
deliveryAddress: true,
billingAddress: false,
contract: true,
});
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
const [insertBankText, setInsertBankText] = useState(false);
const [attachBankPdf, setAttachBankPdf] = useState(false);
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
// / keine Ausstellungsdaten falls der Empfänger nur die Nummer braucht.
const [insertIdentityText, setInsertIdentityText] = useState(false);
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
// Welche E-Mail-Adresse in der Customer-Section steht:
// - 'master' = Stammdaten-E-Mail (customer.email)
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
// - 'none' = E-Mail-Zeile weglassen
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
const [busy, setBusy] = useState(false);
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
// wenn das Modal mal mit anderen Daten wieder aufgeht).
useEffect(() => {
if (isOpen && contract) {
setChecked({
customer: !!customer,
deliveryAddress: !!deliveryAddress,
billingAddress: false, // nur wenn vorhanden, aber default aus
contract: true,
});
setInsertBankText(false);
setAttachBankPdf(false);
setInsertIdentityText(false);
setAttachIdentityPdf(false);
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
setEmailChoice(customer?.email ? 'master' : 'sender');
}
}, [isOpen, contract, customer, deliveryAddress]);
const toggle = (key: SectionKey) => {
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleClose = () => {
if (busy) return;
onClose();
};
const handleConfirm = async () => {
if (!contract) return;
setBusy(true);
try {
const blocks: string[] = [];
if (checked.customer && customer) {
const chosenEmail =
emailChoice === 'master'
? customer.email || ''
: emailChoice === 'sender'
? senderEmail
: '';
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
}
if (checked.deliveryAddress && deliveryAddress) {
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
}
if (checked.billingAddress && billingAddress) {
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
}
if (checked.contract) {
blocks.push(formatContractBlock(contract));
}
if (insertBankText && bankCard) {
blocks.push(formatBankBlock(bankCard));
}
if (insertIdentityText && identityDocument) {
blocks.push(formatIdentityBlock(identityDocument));
}
const textToInsert = blocks
.filter((b) => b.trim().length > 0)
.join('\n\n');
// Anhänge sammeln
const newAttachments: EmailAttachment[] = [];
let runningSize = totalAttachmentBytes(currentAttachments);
const tryAttach = async (
documentPath: string | undefined,
filename: string,
): Promise<boolean> => {
if (!documentPath) return false;
try {
const att = await serverFileToAttachment(documentPath, filename);
const approxBytes = Math.ceil(att.content.length * 0.75);
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
return false;
}
newAttachments.push(att);
runningSize += approxBytes;
return true;
} catch (err: any) {
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
return false;
}
};
if (attachBankPdf && bankCard?.documentPath) {
await tryAttach(
bankCard.documentPath,
bankCardAttachmentName(bankCard.iban),
);
}
if (attachIdentityPdf && identityDocument?.documentPath) {
await tryAttach(
identityDocument.documentPath,
identityDocAttachmentName(
identityDocument.type,
identityDocument.documentNumber,
),
);
}
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
const newBody = textToInsert
? currentBody + separator + textToInsert
: currentBody;
onResult(newBody, newAttachments);
onClose();
} finally {
setBusy(false);
}
};
const nothingSelected =
!checked.customer &&
!checked.deliveryAddress &&
!checked.billingAddress &&
!checked.contract &&
!insertBankText &&
!attachBankPdf &&
!insertIdentityText &&
!attachIdentityPdf;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Kundendaten einfügen"
size="lg"
>
<div className="space-y-4">
{isLoading || !contract ? (
<div className="flex items-center justify-center py-8 text-gray-500">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Daten werden geladen
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{customer && (
<SectionRow
title="Anrede & Name"
checked={checked.customer}
onToggle={() => toggle('customer')}
preview={previewCustomer(customer, contract)}
extra={
checked.customer && (
<div className="mt-2 ml-6 space-y-1">
<div className="text-xs font-medium text-gray-600">
E-Mail im Text:
</div>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'master'}
onChange={() => setEmailChoice('master')}
disabled={!customer.email}
className="text-blue-600"
/>
<span>
Stammdaten-E-Mail
{customer.email ? (
<span className="text-gray-400"> ({customer.email})</span>
) : (
<span className="text-gray-400"> (nicht hinterlegt)</span>
)}
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'sender'}
onChange={() => setEmailChoice('sender')}
className="text-blue-600"
/>
<span>
Absender-Adresse
<span className="text-gray-400"> ({senderEmail})</span>
</span>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="radio"
name="emailChoice"
checked={emailChoice === 'none'}
onChange={() => setEmailChoice('none')}
className="text-blue-600"
/>
<span>Keine E-Mail einfügen</span>
</label>
</div>
)
}
/>
)}
{deliveryAddress && (
<SectionRow
title="Lieferadresse"
checked={checked.deliveryAddress}
onToggle={() => toggle('deliveryAddress')}
preview={previewAddress(deliveryAddress)}
/>
)}
{billingAddress && (
<SectionRow
title="Rechnungsadresse"
checked={checked.billingAddress}
onToggle={() => toggle('billingAddress')}
preview={previewAddress(billingAddress)}
/>
)}
<SectionRow
title="Vertragsdaten"
checked={checked.contract}
onToggle={() => toggle('contract')}
preview={previewContract(contract)}
/>
{bankCard && (
<DualChoiceRow
title="Bankverbindung"
preview={previewBank(bankCard)}
textChecked={insertBankText}
onToggleText={() => setInsertBankText((v) => !v)}
textLabel="Letzte 4 IBAN-Stellen einfügen"
textDisabled={!lastFourIban(bankCard.iban)}
pdfChecked={attachBankPdf}
onTogglePdf={() => setAttachBankPdf((v) => !v)}
pdfLabel="Bankkarte als PDF anhängen"
pdfDisabled={!bankCard.documentPath}
/>
)}
{identityDocument && (
<DualChoiceRow
title={identityTypeLabel(identityDocument.type)}
preview={previewIdentity(identityDocument)}
textChecked={insertIdentityText}
onToggleText={() => setInsertIdentityText((v) => !v)}
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
textDisabled={!identityDocument.documentNumber}
pdfChecked={attachIdentityPdf}
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
pdfDisabled={!identityDocument.documentPath}
/>
)}
{/* Falls weder Customer noch Address etc. da sind */}
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
<p className="text-sm text-gray-500 text-center py-4">
Keine weiteren Daten am Kunden hinterlegt.
</p>
)}
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<span className="text-xs text-gray-500">
Text wird ans Ende der Nachricht angehängt.
</span>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
onClick={handleConfirm}
disabled={busy || isLoading || nothingSelected}
>
{busy ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Einfügen
</>
) : (
'Einfügen'
)}
</Button>
</div>
</div>
</div>
</Modal>
);
}
// ==================== UI-Helper ====================
interface SectionRowProps {
title: string;
checked: boolean;
onToggle: () => void;
preview: string;
extra?: React.ReactNode;
}
interface DualChoiceRowProps {
title: string;
preview: string;
textChecked: boolean;
onToggleText: () => void;
textLabel: string;
textDisabled?: boolean;
pdfChecked: boolean;
onTogglePdf: () => void;
pdfLabel: string;
pdfDisabled?: boolean;
}
/**
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
* Keine primäre Checkbox beide Schalter wirken einzeln, deshalb
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
*/
function DualChoiceRow({
title,
preview,
textChecked,
onToggleText,
textLabel,
textDisabled,
pdfChecked,
onTogglePdf,
pdfLabel,
pdfDisabled,
}: DualChoiceRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="text-xs text-gray-500 mt-1">{preview}</div>
<div className="mt-2 space-y-1">
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={textChecked}
onChange={onToggleText}
disabled={textDisabled}
className="rounded"
/>
<span>{textLabel}</span>
</label>
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
<input
type="checkbox"
checked={pdfChecked}
onChange={onTogglePdf}
disabled={pdfDisabled}
className="rounded"
/>
<span>
{pdfLabel}
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
</span>
</label>
</div>
</div>
);
}
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
return (
<div className="border border-gray-200 rounded-lg p-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="mt-1 rounded"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">{title}</div>
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
{preview}
</pre>
</div>
</label>
{extra}
</div>
);
}
// ==================== Text-Block-Formatierung ====================
function fullName(
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
contractType: string,
): string {
if (contractType === 'BUSINESS' && customer.companyName) {
return customer.companyName;
}
const parts: string[] = [];
if (customer.salutation) parts.push(customer.salutation);
parts.push(customer.firstName);
parts.push(customer.lastName);
return parts.filter(Boolean).join(' ');
}
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
// niemanden relevant ist nur, was der Anbieter selbst vergeben hat
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
// die internen Nummern komplett aus.
function formatCustomerBlock(
customer: NonNullable<Contract['customer']>,
contract: Contract,
email: string,
): string {
const lines: string[] = ['Kundendaten:'];
lines.push(fullName(customer, contract.type));
if (contract.customerNumberAtProvider) {
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
}
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
if (email) lines.push(`E-Mail: ${email}`);
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
return lines.join('\n');
}
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
return [
fullName(customer, contract.type),
contract.customerNumberAtProvider
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
: '',
]
.filter(Boolean)
.join(' · ');
}
function formatAddressBlock(label: string, addr: Address): string {
const lines: string[] = [`${label}:`];
lines.push(`${addr.street} ${addr.houseNumber}`);
lines.push(`${addr.postalCode} ${addr.city}`);
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
lines.push(addr.country);
}
return lines.join('\n');
}
function previewAddress(addr: Address): string {
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
}
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
// vergeben hat. Vertriebsplattform-Nummern bleiben drin die nutzt der
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
function formatContractBlock(c: Contract): string {
const lines: string[] = ['Vertragsdaten:'];
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
return lines.join('\n');
}
function previewContract(c: Contract): string {
const parts: string[] = [];
if (c.contractNumberAtProvider) {
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
} else if (c.provider?.name) {
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
}
if (c.provider?.name) parts.push(c.provider.name);
if (c.tariff?.name) parts.push(c.tariff.name);
return parts.join(' · ');
}
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
// versenden ist sowieso heikel der Empfänger kann sich mit den letzten
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
// IBAN im Mail-Verlauf hängenbleibt.
function lastFourIban(iban: string | undefined | null): string {
if (!iban) return '';
return iban.replace(/\s+/g, '').slice(-4);
}
function formatBankBlock(b: BankCard): string {
const last4 = lastFourIban(b.iban);
if (!last4) return '';
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
}
function previewBank(b: BankCard): string {
const last4 = lastFourIban(b.iban);
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
}
function identityTypeLabel(type: IdentityDocument['type']): string {
switch (type) {
case 'PASSPORT': return 'Reisepass';
case 'DRIVERS_LICENSE': return 'Führerschein';
case 'OTHER': return 'Ausweisdokument';
case 'ID_CARD':
default: return 'Personalausweis';
}
}
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
// Behörde / Daten wenn der Empfänger mehr Details braucht, soll er
// die beigefügte PDF benutzen.
function formatIdentityBlock(d: IdentityDocument): string {
if (!d.documentNumber) return '';
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
}
function previewIdentity(d: IdentityDocument): string {
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
}
@@ -0,0 +1,86 @@
// Hilfs-Funktionen für ComposeEmailModal und die zwei neuen Modals
// (Vertragsdokumente anhängen, Kundendaten einfügen).
import { fileUrl } from '../../utils/fileUrl';
import type { EmailAttachment } from '../../services/api';
/**
* Holt eine Server-Datei (per fileUrl mit Token) und gibt sie als
* EmailAttachment zurück. Wird sowohl für ContractDocuments als auch
* für BankCard- und IdentityDocument-PDFs benutzt.
*
* Wirft mit aussagekräftiger Message, wenn der Download fehlschlägt
* der Caller fängt das ab und zeigt einen Toast.
*/
export async function serverFileToAttachment(
documentPath: string,
filename: string,
): Promise<EmailAttachment> {
const url = fileUrl(documentPath);
if (!url) throw new Error(`Datei "${filename}" hat keinen Pfad.`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Download von "${filename}" fehlgeschlagen (HTTP ${response.status}).`,
);
}
const blob = await response.blob();
const base64 = await blobToBase64(blob);
return {
filename,
content: base64,
contentType: blob.type || 'application/octet-stream',
};
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// data:application/pdf;base64,XYZ → XYZ
const result = reader.result as string;
const base64 = result.split(',')[1] ?? '';
resolve(base64);
};
reader.onerror = () => reject(reader.error || new Error('FileReader-Fehler'));
reader.readAsDataURL(blob);
});
}
/**
* Gesamtgröße aller Anhänge berechnen (in Bytes, näherungsweise).
* Base64 ist ~33% größer als die Original-Bytes.
*/
export function totalAttachmentBytes(attachments: EmailAttachment[]): number {
return attachments.reduce(
(sum, att) => sum + Math.ceil(att.content.length * 0.75),
0,
);
}
/**
* Filename-Vorschlag für eine Bankkarte mit IBAN-Suffix damit beim
* Empfänger klar ist, welches Konto gemeint ist.
*/
export function bankCardAttachmentName(iban: string | undefined): string {
if (!iban) return 'Bankkarte.pdf';
const lastFour = iban.replace(/\s+/g, '').slice(-4);
return `Bankkarte-${lastFour}.pdf`;
}
/**
* Filename-Vorschlag für Ausweis-PDF abhängig vom Typ.
*/
export function identityDocAttachmentName(
type: string,
documentNumber: string | undefined,
): string {
const base = type === 'PASSPORT'
? 'Reisepass'
: type === 'DRIVERS_LICENSE'
? 'Fuehrerschein'
: type === 'OTHER'
? 'Ausweisdokument'
: 'Personalausweis';
return documentNumber ? `${base}-${documentNumber}.pdf` : `${base}.pdf`;
}
+39 -5
View File
@@ -37,6 +37,14 @@ interface JpgToPdfModalProps {
const MAX_IMAGES = 50;
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
// Smartphone-Fotos haben oft 4000-6000 px Kante. Bei JPEG-Quality
// 0.95 sind das 5-10 MB pro Seite, zwei Bilder = >10 MB PDF.
// 2400 px lange Kante entspricht ~290 DPI auf A4 (Druckqualität) und
// reduziert die Pixelmenge auf 25-36 % vom Original → PDF wird
// drastisch kleiner, sichtbarer Unterschied praktisch null.
const MAX_DIMENSION = 2400;
const EMBED_QUALITY = 0.92;
function makeId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -59,6 +67,23 @@ function loadImage(dataUrl: string): Promise<HTMLImageElement> {
});
}
function downscaleIfNeeded(image: HTMLImageElement): HTMLCanvasElement | null {
const w = image.naturalWidth;
const h = image.naturalHeight;
if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) return null;
const scale = MAX_DIMENSION / Math.max(w, h);
const newW = Math.round(w * scale);
const newH = Math.round(h * scale);
const canvas = document.createElement('canvas');
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newW, newH);
return canvas;
}
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@@ -123,13 +148,22 @@ export default function JpgToPdfModal({
break;
}
try {
const dataUrl = await readFileAsDataUrl(file);
const img = await loadImage(dataUrl);
const rawDataUrl = await readFileAsDataUrl(file);
const img = await loadImage(rawDataUrl);
// Beim Hinzufügen direkt auf MAX_DIMENSION runterskalieren, damit
// die Vorschau, das Rendern in der PDF und die finale Dateigröße
// alle auf vernünftigen Pixelmaßen arbeiten.
const downscaled = downscaleIfNeeded(img);
const dataUrl = downscaled
? downscaled.toDataURL('image/jpeg', EMBED_QUALITY)
: rawDataUrl;
const finalW = downscaled ? downscaled.width : img.naturalWidth;
const finalH = downscaled ? downscaled.height : img.naturalHeight;
added.push({
id: makeId(),
dataUrl,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
naturalWidth: finalW,
naturalHeight: finalH,
rotation: 0,
flipH: false,
flipV: false,
@@ -271,7 +305,7 @@ export default function JpgToPdfModal({
} else {
const img = await loadImage(item.dataUrl);
const canvas = renderImageToCanvas(img, item);
imageData = canvas.toDataURL('image/jpeg', 0.95);
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
imageFormat = 'JPEG';
srcW = canvas.width;
srcH = canvas.height;
@@ -1838,6 +1838,16 @@ export default function ContractDetail() {
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</Link>
<a
href={`/customers/${c.customer.id}`}
target="_blank"
rel="noopener noreferrer"
title="Kundenakte in neuem Tab öffnen"
aria-label="Kundenakte in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => setShowCustomerInfo(true)}
@@ -2080,6 +2090,15 @@ export default function ContractDetail() {
</dd>
</div>
)}
{c.orderNumberAtSalesPlatform && (
<div>
<dt className="text-sm text-gray-500">Auftragsnr. Vertriebsplattform</dt>
<dd className="font-mono flex items-center gap-1">
{c.orderNumberAtSalesPlatform}
<CopyButton value={c.orderNumberAtSalesPlatform} />
</dd>
</div>
)}
{c.customerNumberAtSalesPlatform && (
<div>
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
+69 -6
View File
@@ -9,6 +9,8 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import CopyButton from '../../components/ui/CopyButton';
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
@@ -220,6 +222,7 @@ export default function ContractForm() {
// Passwort-Sichtbarkeit
const [showPortalPassword, setShowPortalPassword] = useState(false);
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
const [showInternetPassword, setShowInternetPassword] = useState(false);
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
@@ -302,6 +305,7 @@ export default function ContractForm() {
tariffName: c.tariffName || '',
customerNumberAtProvider: c.customerNumberAtProvider || '',
contractNumberAtProvider: c.contractNumberAtProvider || '',
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
priceFirst12Months: c.priceFirst12Months || '',
@@ -558,6 +562,7 @@ export default function ContractForm() {
tariffName: emptyToNull(data.tariffName),
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
priceFirst12Months: emptyToNull(data.priceFirst12Months),
@@ -772,7 +777,7 @@ export default function ContractForm() {
return (
<div>
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center gap-4 mb-2">
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
<ArrowLeft className="w-4 h-4" />
</Button>
@@ -780,6 +785,36 @@ export default function ContractForm() {
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
</h1>
</div>
{customer && (
<p className="text-gray-500 ml-12 mb-6 flex items-center gap-1">
Kunde:{' '}
<Link
to={`/customers/${customer.id}`}
className="text-blue-600 hover:underline"
>
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</Link>
<a
href={`/customers/${customer.id}`}
target="_blank"
rel="noopener noreferrer"
title="Kundenakte in neuem Tab öffnen"
aria-label="Kundenakte in neuem Tab öffnen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => setShowCustomerInfo(true)}
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
aria-label="Kundendaten anzeigen"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
>
<Info className="w-4 h-4" />
</button>
</p>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
@@ -954,10 +989,11 @@ export default function ContractForm() {
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
disabled={!selectedProviderId}
/>
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
<Input label="Kundennummer bei Vertriebsplattform" {...register('customerNumberAtSalesPlatform')} />
<Input label="Vertragsnummer bei Vertriebsplattform" {...register('contractNumberAtSalesPlatform')} />
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
<Input label="Kundennummer bei Vertriebsplattform" maxLength={100} {...register('customerNumberAtSalesPlatform')} />
<Input label="Vertragsnummer bei Vertriebsplattform" maxLength={100} {...register('contractNumberAtSalesPlatform')} />
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
@@ -1014,7 +1050,24 @@ export default function ContractForm() {
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Portal Benutzername</label>
{(() => {
// Aktiv kopierbaren Wert je nach Modus ermitteln:
// - Manuell: aktueller Eingabewert von portalUsername
// - Stressfrei: E-Mail der ausgewählten Stressfrei-Adresse
const manualUsername = (watch('portalUsername') as string) || '';
const selectedStressfreiEmail = selectedStressfreiEmailId
? stressfreiEmails.find((e: { id: number; email: string }) => e.id.toString() === selectedStressfreiEmailId)?.email
: '';
const copyValue = usernameType === 'manual'
? manualUsername.trim()
: (selectedStressfreiEmail || '');
return (
<label className="flex items-center gap-2 mb-2 text-sm font-medium text-gray-700">
<span>Portal Benutzername</span>
{copyValue && <CopyButton value={copyValue} title={`Benutzername "${copyValue}" in Zwischenablage kopieren`} />}
</label>
);
})()}
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -1032,6 +1085,7 @@ export default function ContractForm() {
{usernameType === 'manual' && (
<Input
{...register('portalUsername')}
maxLength={100}
placeholder="Benutzername eingeben..."
/>
)}
@@ -1742,6 +1796,15 @@ export default function ContractForm() {
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
{/* Kunden-Schnellansicht */}
{customer && (
<CustomerInfoModal
customerId={customer.id}
open={showCustomerInfo}
onClose={() => setShowCustomerInfo(false)}
/>
)}
</div>
);
}
+279 -14
View File
@@ -2679,10 +2679,12 @@ function BankCardModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when bankCard prop changes
if (isEditing && formData.iban !== bankCard.iban) {
// Re-Init nur beim Wechsel zur anderen Karte nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bankCard?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
@@ -2829,10 +2831,12 @@ function DocumentModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when document prop changes
if (isEditing && formData.documentNumber !== document.documentNumber) {
// Re-Init nur beim Wechsel zum anderen Ausweis nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
@@ -3039,10 +3043,12 @@ function MeterModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when meter prop changes
if (isEditing && formData.meterNumber !== meter.meterNumber) {
// Re-Init nur beim Wechsel zum anderen Zähler nicht bei jedem
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meter?.id]);
const noDeliveryAddresses = deliveryAddresses.length === 0;
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
@@ -3276,10 +3282,12 @@ function MeterReadingModal({
const isPending = createMutation.isPending || updateMutation.isPending;
// Update form when reading prop changes
if (isEditing && formData.value !== reading.value.toString()) {
// Re-Init nur beim Wechsel zum anderen Zählerstand nicht bei
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
useEffect(() => {
setFormData(getInitialFormData());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reading?.id]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
@@ -3736,6 +3744,8 @@ function StressfreiEmailModal({
} | null>(null);
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showForwardsModal, setShowForwardsModal] = useState(false);
const [additionalForwards, setAdditionalForwards] = useState<string[]>([]);
const queryClient = useQueryClient();
const isEditing = !!email;
@@ -3874,6 +3884,15 @@ function StressfreiEmailModal({
setNotes(email.notes || '');
setProviderStatus('idle');
setMailboxEnabled(email.hasMailbox || false);
// Aktuelle Zusatz-Weiterleitungen aus dem JSON-Feld parsen.
let parsed: string[] = [];
if (email.additionalForwardingEmails) {
try {
const x = JSON.parse(email.additionalForwardingEmails);
if (Array.isArray(x)) parsed = x.filter((s): s is string => typeof s === 'string');
} catch {/* fällt auf [] zurück */}
}
setAdditionalForwards(parsed);
// Status beim Provider prüfen wenn Provider vorhanden
if (hasProvider) {
checkProviderStatus(emailLocalPart);
@@ -3887,6 +3906,7 @@ function StressfreiEmailModal({
setCreateMailbox(false);
setProviderStatus('idle');
setMailboxEnabled(false);
setAdditionalForwards([]);
}
setProvisionError(null);
// Zugangsdaten zurücksetzen
@@ -3898,12 +3918,25 @@ function StressfreiEmailModal({
const createMutation = useMutation({
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
return stressfreiEmailApi.create(customerId, {
const result = await stressfreiEmailApi.create(customerId, {
email: data.email,
notes: data.notes,
provisionAtProvider: data.provision,
createMailbox: data.createMailbox,
});
// Wenn der User zusätzliche Weiterleitungen im Sub-Modal gepflegt
// hat: nach der Erstellung gleich am Provider nachziehen. Das
// sorgt für einen `set:`-Sync mit der vollen Liste, kein
// Edit-Modus-Roundtrip nötig.
if (data.provision && additionalForwards.length > 0 && result.data?.id) {
try {
await stressfreiEmailApi.updateAdditionalForwards(result.data.id, additionalForwards);
} catch (e) {
console.error('Zusatz-Weiterleitungen konnten nicht gesetzt werden:', e);
// Adresse selbst wurde angelegt nicht hart fehlschlagen.
}
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
@@ -3912,6 +3945,7 @@ function StressfreiEmailModal({
setNotes('');
setProvisionAtProvider(false);
setCreateMailbox(false);
setAdditionalForwards([]);
onClose();
},
onError: (error) => {
@@ -3926,6 +3960,9 @@ function StressfreiEmailModal({
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
},
onError: (err) => {
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
},
});
const handleSubmit = (e: React.FormEvent) => {
@@ -4167,7 +4204,28 @@ function StressfreiEmailModal({
</div>
)}
<div className="flex justify-end gap-2">
<div className="flex justify-between items-center gap-2">
<div>
{((isEditing && email && providerStatus === 'exists') ||
(!isEditing && provisionAtProvider)) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowForwardsModal(true)}
title="Zusätzliche Weiterleitungs-Adressen pflegen"
>
<Mail className="w-4 h-4 mr-1" />
Weitere Weiterleitungen
{additionalForwards.length > 0 && (
<span className="ml-1 text-xs bg-blue-100 text-blue-700 rounded-full px-1.5">
{additionalForwards.length}
</span>
)}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
@@ -4175,7 +4233,214 @@ function StressfreiEmailModal({
{isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</form>
<AdditionalForwardsModal
isOpen={showForwardsModal}
onClose={() => setShowForwardsModal(false)}
email={email ?? undefined}
customerEmail={customerEmail}
selfEmail={localPart ? localPart + domainSuffix : undefined}
value={additionalForwards}
onChange={setAdditionalForwards}
/>
</Modal>
);
}
// Untermodal: zusätzliche Weiterleitungs-E-Mails verwalten.
// Edit-Modus: `email` prop gesetzt → jede Änderung wird sofort am
// Provider gesynct.
// Create-Modus: `email` undefined → reine lokale Verwaltung über
// `value`/`onChange`. Wird beim createEmail-Submit mitgegeben.
function AdditionalForwardsModal({
isOpen,
onClose,
email,
customerEmail,
selfEmail,
value,
onChange,
}: {
isOpen: boolean;
onClose: () => void;
email?: StressfreiEmail;
customerEmail?: string;
/** Die Stressfrei-Adresse selbst (für Self-Forward-Check im Create-Modus,
* wo es noch kein `email`-Prop gibt). Edit-Modus zieht's aus `email`. */
selfEmail?: string;
/** Aktuelle Liste im Create-Modus controlled, im Edit-Modus initialer Wert. */
value: string[];
onChange: (next: string[]) => void;
}) {
const queryClient = useQueryClient();
const [newEmail, setNewEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
setNewEmail('');
setError(null);
}, [isOpen]);
const EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
const persist = async (next: string[]) => {
setError(null);
if (email) {
// Edit-Modus: sofort am Provider syncen.
setIsSubmitting(true);
try {
await stressfreiEmailApi.updateAdditionalForwards(email.id, next);
onChange(next);
queryClient.invalidateQueries({ queryKey: ['stressfrei-emails', email.customerId] });
} catch (e) {
const msg = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
setError(msg);
throw e;
} finally {
setIsSubmitting(false);
}
} else {
// Create-Modus: nur lokal updaten. Persistierung beim Submit
// des Haupt-Modals.
onChange(next);
}
};
// Plus-Tag wegstrippen + lowercase, identisch zum Backend-canonicalEmailKey.
// Dann landen `billing+x@y` und `billing@y` im selben Key.
const canonicalize = (raw: string) => {
const lower = raw.trim().toLowerCase();
const at = lower.lastIndexOf('@');
if (at < 1) return lower;
const local = lower.slice(0, at);
const plus = local.indexOf('+');
return (plus === -1 ? local : local.slice(0, plus)) + '@' + lower.slice(at + 1);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
const candidate = newEmail.trim().toLowerCase();
if (!candidate) return;
if (!EMAIL_REGEX.test(candidate)) {
setError('Bitte eine gültige E-Mail-Adresse eingeben.');
return;
}
const candidateKey = canonicalize(candidate);
if (customerEmail && candidateKey === canonicalize(customerEmail)) {
setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.');
return;
}
const ownAddress = email?.email ?? selfEmail;
if (ownAddress && candidateKey === canonicalize(ownAddress)) {
setError(`"${candidate}" zeigt auf die Adresse selbst das würde einen Mail-Loop erzeugen.`);
return;
}
if (value.some((f) => canonicalize(f) === candidateKey)) {
setError('Diese Adresse ist schon in der Liste.');
return;
}
try {
await persist([...value, candidate]);
setNewEmail('');
} catch {
/* error wird oben gesetzt */
}
};
const handleRemove = async (target: string) => {
try {
await persist(value.filter((f) => f !== target));
} catch {
/* error wird oben gesetzt */
}
};
const title = email
? `Weiterleitungen für ${email.email}`
: 'Weitere Weiterleitungen';
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Posteingänge gehen immer an die Stamm-E-Mail des Kunden
{customerEmail && (
<> (<span className="font-mono">{customerEmail}</span>)</>
)}
. Hier kannst du zusätzliche Adressen hinterlegen, die ebenfalls eine Kopie bekommen.
{email
? ' Änderungen werden sofort am E-Mail-Provider übernommen.'
: ' Sie werden zusammen mit der Adresse angelegt, sobald du speicherst.'}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aktuelle zusätzliche Ziele
</label>
{value.length === 0 ? (
<p className="text-sm text-gray-500 italic">Noch keine zusätzlichen Adressen.</p>
) : (
<ul className="space-y-1">
{value.map((f) => (
<li
key={f}
className="flex items-center justify-between bg-gray-50 border border-gray-200 rounded px-3 py-2"
>
<span className="font-mono text-sm">{f}</span>
<button
type="button"
onClick={() => handleRemove(f)}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
disabled={isSubmitting}
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
<form onSubmit={handleAdd} className="border-t pt-3 space-y-2">
<label className="block text-sm font-medium text-gray-700">
Weitere Adresse hinzufügen
</label>
<div className="flex gap-2">
<input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
setError(null);
}}
placeholder="z.B. info@partner.de"
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSubmitting}
maxLength={254}
/>
<Button type="submit" disabled={isSubmitting || !newEmail.trim()}>
<Plus className="w-4 h-4 mr-1" />
Hinzufügen
</Button>
</div>
</form>
{error && (
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end">
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
Schließen
</Button>
</div>
</div>
</Modal>
);
}
@@ -292,6 +292,13 @@ function ProviderModal({
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
// Admin sein eigenes Passwort mitsenden Schutz gegen kompromittierten
@@ -316,6 +323,13 @@ function ProviderModal({
portalUrl: provider.portalUrl || '',
usernameFieldName: provider.usernameFieldName || '',
passwordFieldName: provider.passwordFieldName || '',
contactEmail: provider.contactEmail || '',
contactPhone: provider.contactPhone || '',
contactFax: provider.contactFax || '',
contactAddress: provider.contactAddress || '',
cancellationEmail: provider.cancellationEmail || '',
cancellationFax: provider.cancellationFax || '',
cancellationAddress: provider.cancellationAddress || '',
isActive: provider.isActive,
currentPassword: '',
});
@@ -325,6 +339,13 @@ function ProviderModal({
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
contactEmail: '',
contactPhone: '',
contactFax: '',
contactAddress: '',
cancellationEmail: '',
cancellationFax: '',
cancellationAddress: '',
isActive: true,
currentPassword: '',
});
@@ -434,6 +455,75 @@ function ProviderModal({
/>
</div>
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Kontakt & Kündigung</strong> (optional)<br />
Erreichbarkeit des Anbieters wird im CRM zum Nachschlagen
angezeigt, nicht an Portal-Kunden ausgespielt.
</p>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Kontakt</div>
<Input
label="Kontakt-Emailadresse"
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="z.B. service@anbieter.de"
/>
<Input
label="Kontakt-Telefonnummer"
value={formData.contactPhone}
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
placeholder="z.B. +49 30 1234567"
/>
<Input
label="Kontakt-Faxnummer"
value={formData.contactFax}
onChange={(e) => setFormData({ ...formData, contactFax: e.target.value })}
placeholder="z.B. +49 30 7654321"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kontakt-Postadresse
</label>
<textarea
value={formData.contactAddress}
onChange={(e) => setFormData({ ...formData, contactAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
<div className="pt-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Kündigung</div>
<Input
label="Kündigungs-Emailadresse"
type="email"
value={formData.cancellationEmail}
onChange={(e) => setFormData({ ...formData, cancellationEmail: e.target.value })}
placeholder="z.B. kuendigung@anbieter.de"
/>
<Input
label="Kündigungs-Faxnummer"
value={formData.cancellationFax}
onChange={(e) => setFormData({ ...formData, cancellationFax: e.target.value })}
placeholder="z.B. +49 30 9876543"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kündigungs-Postadresse
</label>
<textarea
value={formData.cancellationAddress}
onChange={(e) => setFormData({ ...formData, cancellationAddress: e.target.value })}
rows={3}
maxLength={500}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Musteranbieter GmbH&#10;Abteilung Kündigung&#10;Musterstraße 1&#10;12345 Berlin"
/>
</div>
</div>
{provider && (
<label className="flex items-center gap-2">
<input
+26 -11
View File
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
isActive: boolean;
isProvisioned?: boolean;
hasMailbox: boolean;
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
additionalForwardingEmails?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
return res.data;
},
// Zusätzliche Weiterleitungs-Adressen ersetzen + sofort am Provider syncen.
updateAdditionalForwards: async (id: number, emails: string[]) => {
const res = await api.put<ApiResponse<{ forwardTargets: string[] }>>(
`/stressfrei-emails/${id}/additional-forwards`,
{ emails },
);
return res.data;
},
// E-Mails synchronisieren
syncEmails: async (id: number, fullSync = false) => {
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
@@ -587,19 +597,24 @@ export const cachedEmailApi = {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
return res.data;
},
// E-Mails für Vertrag abrufen
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
getForContract: async (
contractId: number,
options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number },
) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
return res.data;
},
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
getContractFolderCounts: async (contractId: number) => {
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
getContractFolderCounts: async (contractId: number, accountId?: number) => {
const res = await api.get<ApiResponse<{
inbox: number;
inboxUnread: number;
sent: number;
sentUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`);
trash: number;
trashUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
return res.data;
},
// Mailbox-Konten eines Kunden abrufen
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
return res.data;
},
// ==================== PAPIERKORB ====================
// Papierkorb-E-Mails für Kunden abrufen
getTrash: async (customerId: number) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
return res.data;
},
// Papierkorb-Anzahl für Kunden
getTrashCount: async (customerId: number) => {
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
return res.data;
},
// E-Mail aus Papierkorb wiederherstellen
+8
View File
@@ -396,6 +396,13 @@ export interface Provider {
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
contactEmail?: string;
contactPhone?: string;
contactFax?: string;
contactAddress?: string;
cancellationEmail?: string;
cancellationFax?: string;
cancellationAddress?: string;
isActive: boolean;
tariffs?: Tariff[];
_count?: {
@@ -454,6 +461,7 @@ export interface Contract {
tariffName?: string;
customerNumberAtProvider?: string;
contractNumberAtProvider?: string;
orderNumberAtSalesPlatform?: string;
customerNumberAtSalesPlatform?: string;
contractNumberAtSalesPlatform?: string;
priceFirst12Months?: string;