Compare commits
38 Commits
7c18343a95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe | |||
| a4895374b9 | |||
| ebaee024b6 | |||
| f1b05c56e5 | |||
| 5293af18a5 | |||
| 4ab0340473 | |||
| f1102a24b7 | |||
| c013e1e747 | |||
| caa283e66f | |||
| 18a2e1173b | |||
| 993f2d10f0 | |||
| f02824fe7d | |||
| 8b10316683 | |||
| 26959ec909 | |||
| c8b86ca9a7 | |||
| 0b7bb89ebc | |||
| 9274c0adaf | |||
| dfe2a4b241 | |||
| 194c86409f | |||
| 2becf6cb6a | |||
| 24e152b201 | |||
| a83358bbe6 | |||
| 5bb048c534 | |||
| b3469483ca | |||
| 8992bb7a5d | |||
| 246999be01 | |||
| 96a054aa1a | |||
| 36beac98c9 | |||
| 60851450f6 | |||
| 523eab30d5 | |||
| 2fee13d09e | |||
| 84cbf01706 | |||
| fcc3b04725 | |||
| 101369c205 | |||
| e792fe4185 |
@@ -0,0 +1,7 @@
|
||||
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
|
||||
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
|
||||
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
|
||||
|
||||
ALTER TABLE `Contract`
|
||||
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||
+7
@@ -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;
|
||||
+7
@@ -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;
|
||||
+13
@@ -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;
|
||||
@@ -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,9 @@ 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
|
||||
priceFrom13Months String? // Preis ab 13. Monat
|
||||
priceAfter24Months String? // Preis nach 24 Monaten
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (LOW–MEDIUM, 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));
|
||||
@@ -203,6 +226,10 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
const fieldLabels: Record<string, string> = {
|
||||
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',
|
||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]> {
|
||||
// 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 },
|
||||
};
|
||||
if (options?.stressfreiEmailId) {
|
||||
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||
}
|
||||
if (options?.contractId) {
|
||||
where.contractId = options.contractId;
|
||||
}
|
||||
return prisma.cachedEmail.findMany({
|
||||
where: {
|
||||
isDeleted: true,
|
||||
stressfreiEmail: {
|
||||
customerId,
|
||||
},
|
||||
},
|
||||
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: {
|
||||
isDeleted: true,
|
||||
stressfreiEmail: {
|
||||
customerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 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 },
|
||||
};
|
||||
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)
|
||||
|
||||
@@ -203,6 +203,9 @@ interface ContractCreateData {
|
||||
providerName?: string;
|
||||
tariffName?: string;
|
||||
customerNumberAtProvider?: string;
|
||||
orderNumberAtSalesPlatform?: string;
|
||||
customerNumberAtSalesPlatform?: string;
|
||||
contractNumberAtSalesPlatform?: string;
|
||||
priceFirst12Months?: string;
|
||||
priceFrom13Months?: string;
|
||||
priceAfter24Months?: string;
|
||||
@@ -896,6 +899,10 @@ export async function createRenewalContract(previousContractId: number) {
|
||||
providerName: previousContract.providerName,
|
||||
tariffName: previousContract.tariffName,
|
||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
|
||||
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||
portalUsername: previousContract.portalUsername,
|
||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||
commission: previousContract.commission,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,9 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
|
||||
'tariffName',
|
||||
'customerNumberAtProvider',
|
||||
'contractNumberAtProvider',
|
||||
'orderNumberAtSalesPlatform',
|
||||
'customerNumberAtSalesPlatform',
|
||||
'contractNumberAtSalesPlatform',
|
||||
'portalUsername',
|
||||
'previousProviderName',
|
||||
'previousCustomerNumber',
|
||||
@@ -276,9 +279,14 @@ export function assertSafePdf(buf: Buffer): void {
|
||||
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||
return; // keine PDF → andere Validatoren zuständig
|
||||
}
|
||||
const content = buf.toString('latin1');
|
||||
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
|
||||
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
|
||||
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
|
||||
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
|
||||
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
|
||||
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
|
||||
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
if (pattern.test(scanTarget)) {
|
||||
throw new ApiError(
|
||||
415,
|
||||
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
||||
@@ -335,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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
+455
@@ -97,6 +97,461 @@ 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.1–71.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
|
||||
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
|
||||
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
|
||||
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
|
||||
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
|
||||
|
||||
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
|
||||
- Zwei neue optionale Felder
|
||||
`Contract.customerNumberAtSalesPlatform` +
|
||||
`contractNumberAtSalesPlatform`, Migration
|
||||
`20260603150000_contract_sales_platform_numbers` mit
|
||||
`IF NOT EXISTS`.
|
||||
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
|
||||
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
|
||||
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
|
||||
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
|
||||
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
|
||||
und Audit-Label-Mapping ist gleich mit drin – wurde bisher
|
||||
nicht in VVL-Folgeverträge kopiert.
|
||||
|
||||
- [x] **🆕 Email-Links öffnen im neuen Tab**
|
||||
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
|
||||
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
|
||||
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
|
||||
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
|
||||
|
||||
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
|
||||
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
|
||||
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
|
||||
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
|
||||
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
|
||||
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
|
||||
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
|
||||
von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
|
||||
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
|
||||
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
|
||||
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
|
||||
blockiert, clean PDF weiterhin OK.
|
||||
|
||||
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
||||
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
||||
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
||||
|
||||
@@ -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,15 +312,39 @@ export default function ComposeEmailModal({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Anhang hinzufügen Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Paperclip className="w-4 h-4 mr-2" />
|
||||
Datei anhängen
|
||||
</button>
|
||||
{/* Anhang-/Daten-Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||
@@ -52,6 +52,24 @@ export default function EmailDetail({
|
||||
setLocalStarred(email.isStarred);
|
||||
}, [email.id, email.isStarred]);
|
||||
|
||||
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
|
||||
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
|
||||
const safeHtmlBody = useMemo(() => {
|
||||
if (!email.htmlBody) return '';
|
||||
const sanitized = DOMPurify.sanitize(email.htmlBody, {
|
||||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||
ADD_ATTR: ['target'],
|
||||
});
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = sanitized;
|
||||
wrapper.querySelectorAll('a').forEach((a) => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
return wrapper.innerHTML;
|
||||
}, [email.htmlBody]);
|
||||
|
||||
const toggleStarMutation = useMutation({
|
||||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||||
onMutate: () => {
|
||||
@@ -312,6 +330,16 @@ export default function EmailDetail({
|
||||
{email.contract.contractNumber}
|
||||
</Link>
|
||||
</span>
|
||||
<a
|
||||
href={`/contracts/${email.contract.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-green-600 hover:text-green-800"
|
||||
title="Vertrag in neuem Tab öffnen"
|
||||
aria-label="Vertrag in neuem Tab öffnen"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
||||
{!email.isAutoAssigned && (
|
||||
<button
|
||||
@@ -411,16 +439,7 @@ export default function EmailDetail({
|
||||
{showHtml && email.htmlBody ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(email.htmlBody, {
|
||||
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
|
||||
// Bilder + Links mit target=_blank bleiben zugelassen.
|
||||
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
|
||||
ADD_ATTR: ['target'],
|
||||
}),
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtmlBody }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode, useState, useEffect } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
@@ -11,9 +12,21 @@ interface TabsProps {
|
||||
defaultTab?: string;
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
/**
|
||||
* Optional: liefert die URL, unter der ein einzelner Tab in einem
|
||||
* neuen Tab geöffnet werden kann. Wenn gesetzt, erscheint neben jedem
|
||||
* Tab-Label ein kleines „im neuen Tab öffnen"-Icon.
|
||||
*/
|
||||
tabHrefBuilder?: (tabId: string) => string;
|
||||
}
|
||||
|
||||
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
defaultTab,
|
||||
activeTab: controlledTab,
|
||||
onTabChange,
|
||||
tabHrefBuilder,
|
||||
}: TabsProps) {
|
||||
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
||||
const activeTab = controlledTab ?? internalTab;
|
||||
|
||||
@@ -31,19 +44,33 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px space-x-8">
|
||||
<nav className="flex -mb-px space-x-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
<div key={tab.id} className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{tabHrefBuilder && (
|
||||
<a
|
||||
href={tabHrefBuilder(tab.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-gray-400 hover:text-blue-600 p-0.5"
|
||||
title={`${tab.label} in neuem Tab öffnen`}
|
||||
aria-label={`${tab.label} in neuem Tab öffnen`}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2080,6 +2080,33 @@ 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>
|
||||
<dd className="font-mono flex items-center gap-1">
|
||||
{c.customerNumberAtSalesPlatform}
|
||||
<CopyButton value={c.customerNumberAtSalesPlatform} />
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{c.contractNumberAtSalesPlatform && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Vertragsnr. Vertriebsplattform</dt>
|
||||
<dd className="font-mono flex items-center gap-1">
|
||||
{c.contractNumberAtSalesPlatform}
|
||||
<CopyButton value={c.contractNumberAtSalesPlatform} />
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{c.salesPlatform && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 type { ContractType } from '../../types';
|
||||
import { formatDate } from '../../utils/dateFormat';
|
||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||
@@ -302,6 +303,9 @@ 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 || '',
|
||||
priceFrom13Months: c.priceFrom13Months || '',
|
||||
priceAfter24Months: c.priceAfter24Months || '',
|
||||
@@ -556,6 +560,9 @@ 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),
|
||||
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
||||
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
||||
@@ -950,8 +957,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 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" />
|
||||
@@ -1008,7 +1018,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
|
||||
@@ -1026,6 +1053,7 @@ export default function ContractForm() {
|
||||
{usernameType === 'manual' && (
|
||||
<Input
|
||||
{...register('portalUsername')}
|
||||
maxLength={100}
|
||||
placeholder="Benutzername eingeben..."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -411,7 +411,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Tabs tabs={tabs} defaultTab={defaultTab} activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
defaultTab={defaultTab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tabHrefBuilder={(tabId) => `${location.pathname}?tab=${tabId}`}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddressModal
|
||||
@@ -2673,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'}>
|
||||
@@ -2823,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'}>
|
||||
@@ -3033,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;
|
||||
@@ -3270,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'}>
|
||||
@@ -3730,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;
|
||||
|
||||
@@ -3868,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);
|
||||
@@ -3881,6 +3906,7 @@ function StressfreiEmailModal({
|
||||
setCreateMailbox(false);
|
||||
setProviderStatus('idle');
|
||||
setMailboxEnabled(false);
|
||||
setAdditionalForwards([]);
|
||||
}
|
||||
setProvisionError(null);
|
||||
// Zugangsdaten zurücksetzen
|
||||
@@ -3892,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] });
|
||||
@@ -3906,6 +3945,7 @@ function StressfreiEmailModal({
|
||||
setNotes('');
|
||||
setProvisionAtProvider(false);
|
||||
setCreateMailbox(false);
|
||||
setAdditionalForwards([]);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -3920,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) => {
|
||||
@@ -4161,15 +4204,243 @@ function StressfreiEmailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !localPart}>
|
||||
{isPending ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
<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>
|
||||
<Button type="submit" disabled={isPending || !localPart}>
|
||||
{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 Musterstraße 1 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 Abteilung Kündigung Musterstraße 1 12345 Berlin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider && (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 @@ export interface Contract {
|
||||
tariffName?: string;
|
||||
customerNumberAtProvider?: string;
|
||||
contractNumberAtProvider?: string;
|
||||
orderNumberAtSalesPlatform?: string;
|
||||
customerNumberAtSalesPlatform?: string;
|
||||
contractNumberAtSalesPlatform?: string;
|
||||
priceFirst12Months?: string;
|
||||
priceFrom13Months?: string;
|
||||
priceAfter24Months?: string;
|
||||
|
||||
Reference in New Issue
Block a user