Compare commits
34 Commits
2fee13d09e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b23ebeefc3 | |||
| 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 |
+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)?
|
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
||||||
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
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
|
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
||||||
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -570,6 +576,15 @@ model Provider {
|
|||||||
portalUrl String? // Kundenkontourl (Login-Seite)
|
portalUrl String? // Kundenkontourl (Login-Seite)
|
||||||
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
||||||
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
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)
|
isActive Boolean @default(true)
|
||||||
tariffs Tariff[]
|
tariffs Tariff[]
|
||||||
contracts Contract[]
|
contracts Contract[]
|
||||||
@@ -687,6 +702,7 @@ model Contract {
|
|||||||
tariffName String?
|
tariffName String?
|
||||||
customerNumberAtProvider String?
|
customerNumberAtProvider String?
|
||||||
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
||||||
|
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
|
||||||
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
||||||
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
||||||
priceFirst12Months String? // Preis erste 12 Monate
|
priceFirst12Months String? // Preis erste 12 Monate
|
||||||
|
|||||||
@@ -41,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
|
|||||||
return isNaN(d.getTime()) ? undefined : d;
|
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
|
// E-Mails für einen Kunden abrufen
|
||||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
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 folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
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 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> {
|
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
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 limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getCachedEmails({
|
const emails = await cachedEmailService.getCachedEmails({
|
||||||
contractId,
|
contractId,
|
||||||
|
stressfreiEmailId,
|
||||||
folder,
|
folder,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
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> {
|
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
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);
|
res.json({ success: true, data: counts } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -311,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
|||||||
return false;
|
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
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -327,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
return;
|
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
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -882,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
// ==================== TRASH OPERATIONS ====================
|
// ==================== 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> {
|
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
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);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
} catch (error) {
|
} 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> {
|
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
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);
|
res.json({ success: true, data: { count } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as authorizationService from '../services/authorization.service.js';
|
|||||||
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.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 { ApiError } from '../utils/apiError.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.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 (body === null || body === undefined) return body;
|
||||||
if (typeof body === 'string') {
|
if (typeof body === 'string') {
|
||||||
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
|
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);
|
return stripHtml(body);
|
||||||
}
|
}
|
||||||
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
||||||
@@ -204,6 +227,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||||
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
||||||
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
||||||
|
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
|
||||||
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
||||||
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
||||||
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
||||||
|
|||||||
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
|||||||
// Stored-XSS gerendert.
|
// Stored-XSS gerendert.
|
||||||
//
|
//
|
||||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
|
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||||
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
|
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||||
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
|
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||||
// Bei Mismatch fällt's auf attachment zurück – Stored XSS bleibt
|
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||||
// weiterhin unmöglich.
|
// 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 filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
const wantsInline = req.query.disposition === 'inline';
|
const wantsInline = req.query.disposition === 'inline';
|
||||||
let useInline = false;
|
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||||
let inlineContentType: string | null = null;
|
|
||||||
if (wantsInline) {
|
if (wantsInline && !safeContentType) {
|
||||||
try {
|
console.warn(
|
||||||
const fd = fs.openSync(absolute, 'r');
|
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
if (useInline && inlineContentType) {
|
if (safeContentType) {
|
||||||
res.setHeader('Content-Type', inlineContentType);
|
res.setHeader('Content-Type', safeContentType);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
} else {
|
} else {
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
}
|
}
|
||||||
res.sendFile(absolute);
|
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 { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.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> {
|
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
// requireCustomerAccess in der Route greift nicht ausreichend:
|
// requireCustomerAccess in der Route greift nicht ausreichend:
|
||||||
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
||||||
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
// 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> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
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> {
|
export async function createEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
const email = await stressfreiEmailService.createEmail({
|
const email = await stressfreiEmailService.createEmail({
|
||||||
...req.body,
|
...req.body,
|
||||||
customerId,
|
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);
|
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} 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> {
|
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -86,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} 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> {
|
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
await stressfreiEmailService.deleteEmail(emailId);
|
await stressfreiEmailService.deleteEmail(emailId);
|
||||||
await logChange({
|
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> {
|
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
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> {
|
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
||||||
if (!result.success) {
|
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)
|
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
||||||
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
|||||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
|
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||||
export async function getFolderCountsForContract(contractId: number): Promise<{
|
// 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;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
}> {
|
}> {
|
||||||
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
|
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
|
||||||
// INBOX total
|
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
|
||||||
prisma.cachedEmail.count({
|
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
|
||||||
// INBOX unread
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
|
||||||
prisma.cachedEmail.count({
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, 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 },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
return { inbox, inboxUnread, sent, sentUnread };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle StressfreiEmails eines Kunden mit Mailbox
|
// 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
|
// 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({
|
return prisma.cachedEmail.findMany({
|
||||||
where: {
|
where,
|
||||||
isDeleted: true,
|
|
||||||
stressfreiEmail: {
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
stressfreiEmail: {
|
stressfreiEmail: {
|
||||||
select: {
|
select: {
|
||||||
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
|
|||||||
}) as Promise<CachedEmailWithRelations[]>;
|
}) as Promise<CachedEmailWithRelations[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-E-Mails zählen
|
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
|
||||||
export async function getTrashCount(customerId: number): Promise<number> {
|
export async function getTrashCount(
|
||||||
return prisma.cachedEmail.count({
|
customerId: number,
|
||||||
where: {
|
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||||
isDeleted: true,
|
): Promise<number> {
|
||||||
stressfreiEmail: {
|
const where: Prisma.CachedEmailWhereInput = {
|
||||||
customerId,
|
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)
|
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ interface ContractCreateData {
|
|||||||
providerName?: string;
|
providerName?: string;
|
||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
customerNumberAtSalesPlatform?: string;
|
customerNumberAtSalesPlatform?: string;
|
||||||
contractNumberAtSalesPlatform?: string;
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
@@ -899,6 +900,7 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
tariffName: previousContract.tariffName,
|
tariffName: previousContract.tariffName,
|
||||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||||
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||||
|
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
|
||||||
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||||
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||||
portalUsername: previousContract.portalUsername,
|
portalUsername: previousContract.portalUsername,
|
||||||
|
|||||||
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
|
|
||||||
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
||||||
let hasMailbox: boolean | undefined;
|
let hasMailbox: boolean | undefined;
|
||||||
|
let mailgroupActive: boolean | undefined;
|
||||||
|
let mailgroupMembers: string[] | undefined;
|
||||||
|
let forwardingActive: boolean | undefined;
|
||||||
|
let forwardingTargets: string[] | undefined;
|
||||||
if (exists && result.stdout) {
|
if (exists && result.stdout) {
|
||||||
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
||||||
if (mailboxMatch) {
|
if (mailboxMatch) {
|
||||||
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
|
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 {
|
return {
|
||||||
exists,
|
exists,
|
||||||
email: exists ? email : undefined,
|
email: exists ? email : undefined,
|
||||||
hasMailbox,
|
hasMailbox,
|
||||||
|
mailgroupActive,
|
||||||
|
mailgroupMembers,
|
||||||
|
forwardingActive,
|
||||||
|
forwardingTargets,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// HTTP-Fehler oder Netzwerkfehler
|
// HTTP-Fehler oder Netzwerkfehler
|
||||||
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plesk CLI API: Weiterleitungsziele aktualisieren
|
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
|
||||||
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
|
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
|
||||||
await this.request('POST', '/api/v2/cli/mail/call', {
|
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
|
||||||
params: [
|
// 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,
|
'--update', email,
|
||||||
'-forwarding', 'true',
|
'-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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
|
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 {
|
export interface EmailOperationResult {
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface ProviderExport {
|
|||||||
portalUrl: string | null;
|
portalUrl: string | null;
|
||||||
usernameFieldName: string | null;
|
usernameFieldName: string | null;
|
||||||
passwordFieldName: 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;
|
isActive: boolean;
|
||||||
tariffs: { name: string; isActive: boolean }[];
|
tariffs: { name: string; isActive: boolean }[];
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
|
|||||||
portalUrl: p.portalUrl,
|
portalUrl: p.portalUrl,
|
||||||
usernameFieldName: p.usernameFieldName,
|
usernameFieldName: p.usernameFieldName,
|
||||||
passwordFieldName: p.passwordFieldName,
|
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,
|
isActive: p.isActive,
|
||||||
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
||||||
})),
|
})),
|
||||||
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? 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,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? 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,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
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';
|
import { validateHttpUrl } from '../utils/url.js';
|
||||||
|
|
||||||
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
// 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
|
// Aktuell escapt React das Textnode, also kein direkter XSS – aber neue
|
||||||
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
||||||
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
|
// 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 };
|
const out: any = { ...data };
|
||||||
if (typeof out.name === 'string') out.name = stripHtml(out.name);
|
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
|
||||||
if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName);
|
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
|
||||||
if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName);
|
}
|
||||||
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProvider(data: {
|
interface ProviderWritable {
|
||||||
name: string;
|
name?: string;
|
||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: 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 clean = stripProviderStrings(data);
|
||||||
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
||||||
return prisma.provider.create({
|
return prisma.provider.create({
|
||||||
data: {
|
data: {
|
||||||
...clean,
|
...clean,
|
||||||
|
name: clean.name,
|
||||||
portalUrl,
|
portalUrl,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProvider(
|
export async function updateProvider(id: number, data: ProviderWritable) {
|
||||||
id: number,
|
|
||||||
data: {
|
|
||||||
name?: string;
|
|
||||||
portalUrl?: string;
|
|
||||||
usernameFieldName?: string;
|
|
||||||
passwordFieldName?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
||||||
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
||||||
// damit Prisma nicht den alten Wert hält.
|
// damit Prisma nicht den alten Wert hält.
|
||||||
const updateData: typeof data = stripProviderStrings(data);
|
const updateData: any = stripProviderStrings(data);
|
||||||
if (data.portalUrl !== undefined) {
|
if (data.portalUrl !== undefined) {
|
||||||
const validated = assertValidPortalUrl(data.portalUrl);
|
const validated = assertValidPortalUrl(data.portalUrl);
|
||||||
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
|
updateData.portalUrl = validated ?? null;
|
||||||
}
|
}
|
||||||
return prisma.provider.update({
|
return prisma.provider.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -11,6 +11,75 @@ import {
|
|||||||
getActiveProviderConfig,
|
getActiveProviderConfig,
|
||||||
} from './emailProvider/emailProviderService.js';
|
} from './emailProvider/emailProviderService.js';
|
||||||
import { generateSecurePassword } from '../utils/passwordGenerator.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) {
|
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
||||||
const where: Record<string, unknown> = { customerId };
|
const where: Record<string, unknown> = { customerId };
|
||||||
@@ -83,6 +152,27 @@ export interface CreateEmailData {
|
|||||||
export async function createEmail(data: CreateEmailData) {
|
export async function createEmail(data: CreateEmailData) {
|
||||||
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
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
|
// Falls beim Provider anlegen gewünscht
|
||||||
if (provisionAtProvider) {
|
if (provisionAtProvider) {
|
||||||
// Kunde laden für Weiterleitung
|
// Kunde laden für Weiterleitung
|
||||||
@@ -153,6 +243,34 @@ export async function updateEmail(
|
|||||||
isActive?: boolean;
|
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({
|
return prisma.stressfreiEmail.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
|
|||||||
return prisma.stressfreiEmail.delete({ where: { id } });
|
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)
|
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
||||||
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
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).
|
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
||||||
export async function syncForwardingForEmail(
|
export async function syncForwardingForEmail(
|
||||||
id: number,
|
id: number,
|
||||||
|
options: { autoImportPleskMembers?: boolean } = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
forwardTargets?: string[];
|
forwardTargets?: string[];
|
||||||
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
|
|||||||
passwordReset?: boolean;
|
passwordReset?: boolean;
|
||||||
error?: string;
|
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({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
|
|||||||
isProvisioned: true,
|
isProvisioned: true,
|
||||||
hasMailbox: true,
|
hasMailbox: true,
|
||||||
emailPasswordEncrypted: true,
|
emailPasswordEncrypted: true,
|
||||||
|
additionalForwardingEmails: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
|
|||||||
if (config?.defaultForwardEmail) {
|
if (config?.defaultForwardEmail) {
|
||||||
forwardTargets.push(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];
|
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);
|
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
||||||
if (!forwardResult.success) {
|
if (!forwardResult.success) {
|
||||||
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const CONTRACT_DISPLAY_STRING_FIELDS = [
|
|||||||
'tariffName',
|
'tariffName',
|
||||||
'customerNumberAtProvider',
|
'customerNumberAtProvider',
|
||||||
'contractNumberAtProvider',
|
'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
'customerNumberAtSalesPlatform',
|
'customerNumberAtSalesPlatform',
|
||||||
'contractNumberAtSalesPlatform',
|
'contractNumberAtSalesPlatform',
|
||||||
'portalUsername',
|
'portalUsername',
|
||||||
@@ -342,6 +343,158 @@ export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): strin
|
|||||||
return trimmed;
|
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;
|
const NOTES_DEFAULT_MAX = 2000;
|
||||||
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
||||||
if (raw == null) return 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?
|
## 🧭 Wann ist „dicht" dicht?
|
||||||
|
|
||||||
100 % gibt es nicht. Erreicht ist:
|
100 % gibt es nicht. Erreicht ist:
|
||||||
|
|||||||
+414
@@ -97,6 +97,420 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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"**
|
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
|
||||||
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
||||||
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FileText, Loader2, ExternalLink } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
|
||||||
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onAttach: (added: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
|
||||||
|
|
||||||
|
export default function AttachContractDocumentsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
currentAttachments,
|
||||||
|
onAttach,
|
||||||
|
}: Props) {
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract-documents', contractId],
|
||||||
|
queryFn: () => contractApi.getDocuments(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = data?.data || [];
|
||||||
|
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return; // Kein Abbruch während Download läuft
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const docsToFetch = documents.filter((d) => selected.has(d.id));
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
try {
|
||||||
|
for (const doc of docsToFetch) {
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(
|
||||||
|
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
|
||||||
|
{ duration: 6000 },
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
onAttach(newAttachments);
|
||||||
|
toast.success(
|
||||||
|
newAttachments.length === 1
|
||||||
|
? '1 Dokument angehängt'
|
||||||
|
: `${newAttachments.length} Dokumente angehängt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach documentType gruppieren für übersichtliche Darstellung
|
||||||
|
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
|
||||||
|
const key = doc.documentType || 'Sonstiges';
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(doc);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Vertragsdokumente anhängen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Dokumente werden geladen…
|
||||||
|
</div>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<FileText className="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||||
|
{Object.entries(grouped).map(([type, docs]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
{type}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(doc.id)}
|
||||||
|
onChange={() => toggle(doc.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-0.5 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="truncate">{doc.originalName}</span>
|
||||||
|
</div>
|
||||||
|
{doc.notes && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
|
||||||
|
{doc.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href={viewUrl(doc.documentPath)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
title="Dokument in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
<span>Vorschau</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAttach}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Anhängen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Anhängen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { 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 toast from 'react-hot-toast';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
|
||||||
|
import InsertCustomerDataModal from './InsertCustomerDataModal';
|
||||||
|
|
||||||
interface ComposeEmailModalProps {
|
interface ComposeEmailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
|
||||||
|
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Formular bei Modal-Öffnung initialisieren
|
// Formular bei Modal-Öffnung initialisieren
|
||||||
@@ -308,15 +312,39 @@ export default function ComposeEmailModal({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Anhang hinzufügen Button */}
|
{/* Anhang-/Daten-Buttons */}
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
type="button"
|
||||||
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
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
|
<Paperclip className="w-4 h-4 mr-2" />
|
||||||
</button>
|
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 */}
|
{/* Anhang-Liste */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
import TrashEmailList from './TrashEmailList';
|
import TrashEmailList from './TrashEmailList';
|
||||||
@@ -49,28 +50,44 @@ export default function ContractEmailsSection({
|
|||||||
|
|
||||||
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
|
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({
|
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
|
||||||
queryKey: ['emails', 'contract', contractId, selectedFolder],
|
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
|
||||||
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
|
queryFn: () => cachedEmailApi.getForContract(contractId, {
|
||||||
enabled: selectedFolder !== 'TRASH',
|
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||||
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emails = emailsData?.data || [];
|
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({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
contractId,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
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({
|
const { data: folderCountsData } = useQuery({
|
||||||
queryKey: ['contract-folder-counts', contractId],
|
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
|
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
|
||||||
|
enabled: !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const folderCounts = folderCountsData?.data || {
|
const folderCounts = folderCountsData?.data || {
|
||||||
@@ -78,16 +95,6 @@ export default function ContractEmailsSection({
|
|||||||
inboxUnread: 0,
|
inboxUnread: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
sentUnread: 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,
|
trash: 0,
|
||||||
trashUnread: 0,
|
trashUnread: 0,
|
||||||
};
|
};
|
||||||
@@ -358,11 +365,23 @@ export default function ContractEmailsSection({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<Inbox className="w-4 h-4 text-gray-500" />
|
<Inbox className="w-4 h-4 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -429,18 +448,18 @@ export default function ContractEmailsSection({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Papierkorb
|
Papierkorb
|
||||||
{accountFolderCounts.trash > 0 && (
|
{folderCounts.trash > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
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-red-100 text-red-600 font-medium'
|
||||||
: 'bg-gray-100 text-gray-500'
|
: 'bg-gray-100 text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
|
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
|
||||||
>
|
>
|
||||||
{accountFolderCounts.trashUnread > 0
|
{folderCounts.trashUnread > 0
|
||||||
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
|
? `${folderCounts.trashUnread}/${folderCounts.trash}`
|
||||||
: accountFolderCounts.trash}
|
: folderCounts.trash}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -470,9 +489,13 @@ export default function ContractEmailsSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
<div
|
||||||
{/* Email List */}
|
className="flex -mx-6 -mb-6"
|
||||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
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' ? (
|
{selectedFolder === 'TRASH' ? (
|
||||||
<TrashEmailList
|
<TrashEmailList
|
||||||
emails={trashEmails}
|
emails={trashEmails}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailList from './EmailList';
|
import EmailList from './EmailList';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
|
|
||||||
const emails = emailsData?.data || [];
|
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({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
const trashEmails = trashData?.data || [];
|
||||||
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||||
{/* Account Selector */}
|
{/* Account Selector */}
|
||||||
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
<Inbox className="w-5 h-5 text-gray-500" />
|
<Inbox className="w-5 h-5 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import {
|
||||||
|
bankCardAttachmentName,
|
||||||
|
identityDocAttachmentName,
|
||||||
|
serverFileToAttachment,
|
||||||
|
totalAttachmentBytes,
|
||||||
|
} from './composeAttachmentHelpers';
|
||||||
|
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
/**
|
||||||
|
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
|
||||||
|
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
|
||||||
|
* E-Mail angeboten – User-Wunsch 2026-06-21: bei Kundendaten wählen,
|
||||||
|
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
|
||||||
|
* eingefügt wird.
|
||||||
|
*/
|
||||||
|
senderEmail: string;
|
||||||
|
currentBody: string;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailChoice = 'master' | 'sender' | 'none';
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
type SectionKey =
|
||||||
|
| 'customer'
|
||||||
|
| 'deliveryAddress'
|
||||||
|
| 'billingAddress'
|
||||||
|
| 'contract';
|
||||||
|
|
||||||
|
export default function InsertCustomerDataModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
senderEmail,
|
||||||
|
currentBody,
|
||||||
|
currentAttachments,
|
||||||
|
onResult,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract', contractId, 'for-insert-data'],
|
||||||
|
queryFn: () => contractApi.getById(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contract = data?.data;
|
||||||
|
const customer = contract?.customer;
|
||||||
|
const deliveryAddress = contract?.address;
|
||||||
|
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
|
||||||
|
// von der Lieferadresse unterscheidet – sonst doppelt im Text.
|
||||||
|
const billingAddress = useMemo(() => {
|
||||||
|
if (!contract?.billingAddress) return undefined;
|
||||||
|
if (!deliveryAddress) return contract.billingAddress;
|
||||||
|
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
|
||||||
|
}, [contract?.billingAddress, deliveryAddress]);
|
||||||
|
|
||||||
|
const bankCard = contract?.bankCard;
|
||||||
|
const identityDocument = contract?.identityDocument;
|
||||||
|
|
||||||
|
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
|
||||||
|
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
|
||||||
|
// entscheiden, was vertraulich verschickt wird).
|
||||||
|
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
|
||||||
|
customer: true,
|
||||||
|
deliveryAddress: true,
|
||||||
|
billingAddress: false,
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
|
||||||
|
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
|
||||||
|
const [insertBankText, setInsertBankText] = useState(false);
|
||||||
|
const [attachBankPdf, setAttachBankPdf] = useState(false);
|
||||||
|
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
|
||||||
|
// / keine Ausstellungsdaten – falls der Empfänger nur die Nummer braucht.
|
||||||
|
const [insertIdentityText, setInsertIdentityText] = useState(false);
|
||||||
|
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
|
||||||
|
// Welche E-Mail-Adresse in der Customer-Section steht:
|
||||||
|
// - 'master' = Stammdaten-E-Mail (customer.email)
|
||||||
|
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
|
||||||
|
// - 'none' = E-Mail-Zeile weglassen
|
||||||
|
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
|
||||||
|
// wenn das Modal mal mit anderen Daten wieder aufgeht).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && contract) {
|
||||||
|
setChecked({
|
||||||
|
customer: !!customer,
|
||||||
|
deliveryAddress: !!deliveryAddress,
|
||||||
|
billingAddress: false, // nur wenn vorhanden, aber default aus
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
setInsertBankText(false);
|
||||||
|
setAttachBankPdf(false);
|
||||||
|
setInsertIdentityText(false);
|
||||||
|
setAttachIdentityPdf(false);
|
||||||
|
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
|
||||||
|
setEmailChoice(customer?.email ? 'master' : 'sender');
|
||||||
|
}
|
||||||
|
}, [isOpen, contract, customer, deliveryAddress]);
|
||||||
|
|
||||||
|
const toggle = (key: SectionKey) => {
|
||||||
|
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!contract) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
if (checked.customer && customer) {
|
||||||
|
const chosenEmail =
|
||||||
|
emailChoice === 'master'
|
||||||
|
? customer.email || ''
|
||||||
|
: emailChoice === 'sender'
|
||||||
|
? senderEmail
|
||||||
|
: '';
|
||||||
|
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
|
||||||
|
}
|
||||||
|
if (checked.deliveryAddress && deliveryAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
|
||||||
|
}
|
||||||
|
if (checked.billingAddress && billingAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
|
||||||
|
}
|
||||||
|
if (checked.contract) {
|
||||||
|
blocks.push(formatContractBlock(contract));
|
||||||
|
}
|
||||||
|
if (insertBankText && bankCard) {
|
||||||
|
blocks.push(formatBankBlock(bankCard));
|
||||||
|
}
|
||||||
|
if (insertIdentityText && identityDocument) {
|
||||||
|
blocks.push(formatIdentityBlock(identityDocument));
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToInsert = blocks
|
||||||
|
.filter((b) => b.trim().length > 0)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
// Anhänge sammeln
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
|
||||||
|
const tryAttach = async (
|
||||||
|
documentPath: string | undefined,
|
||||||
|
filename: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!documentPath) return false;
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(documentPath, filename);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachBankPdf && bankCard?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
bankCard.documentPath,
|
||||||
|
bankCardAttachmentName(bankCard.iban),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attachIdentityPdf && identityDocument?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
identityDocument.documentPath,
|
||||||
|
identityDocAttachmentName(
|
||||||
|
identityDocument.type,
|
||||||
|
identityDocument.documentNumber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
|
||||||
|
const newBody = textToInsert
|
||||||
|
? currentBody + separator + textToInsert
|
||||||
|
: currentBody;
|
||||||
|
|
||||||
|
onResult(newBody, newAttachments);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nothingSelected =
|
||||||
|
!checked.customer &&
|
||||||
|
!checked.deliveryAddress &&
|
||||||
|
!checked.billingAddress &&
|
||||||
|
!checked.contract &&
|
||||||
|
!insertBankText &&
|
||||||
|
!attachBankPdf &&
|
||||||
|
!insertIdentityText &&
|
||||||
|
!attachIdentityPdf;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Kundendaten einfügen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading || !contract ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Daten werden geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{customer && (
|
||||||
|
<SectionRow
|
||||||
|
title="Anrede & Name"
|
||||||
|
checked={checked.customer}
|
||||||
|
onToggle={() => toggle('customer')}
|
||||||
|
preview={previewCustomer(customer, contract)}
|
||||||
|
extra={
|
||||||
|
checked.customer && (
|
||||||
|
<div className="mt-2 ml-6 space-y-1">
|
||||||
|
<div className="text-xs font-medium text-gray-600">
|
||||||
|
E-Mail im Text:
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'master'}
|
||||||
|
onChange={() => setEmailChoice('master')}
|
||||||
|
disabled={!customer.email}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Stammdaten-E-Mail
|
||||||
|
{customer.email ? (
|
||||||
|
<span className="text-gray-400"> ({customer.email})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400"> (nicht hinterlegt)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'sender'}
|
||||||
|
onChange={() => setEmailChoice('sender')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Absender-Adresse
|
||||||
|
<span className="text-gray-400"> ({senderEmail})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'none'}
|
||||||
|
onChange={() => setEmailChoice('none')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>Keine E-Mail einfügen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deliveryAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Lieferadresse"
|
||||||
|
checked={checked.deliveryAddress}
|
||||||
|
onToggle={() => toggle('deliveryAddress')}
|
||||||
|
preview={previewAddress(deliveryAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{billingAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Rechnungsadresse"
|
||||||
|
checked={checked.billingAddress}
|
||||||
|
onToggle={() => toggle('billingAddress')}
|
||||||
|
preview={previewAddress(billingAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SectionRow
|
||||||
|
title="Vertragsdaten"
|
||||||
|
checked={checked.contract}
|
||||||
|
onToggle={() => toggle('contract')}
|
||||||
|
preview={previewContract(contract)}
|
||||||
|
/>
|
||||||
|
{bankCard && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title="Bankverbindung"
|
||||||
|
preview={previewBank(bankCard)}
|
||||||
|
textChecked={insertBankText}
|
||||||
|
onToggleText={() => setInsertBankText((v) => !v)}
|
||||||
|
textLabel="Letzte 4 IBAN-Stellen einfügen"
|
||||||
|
textDisabled={!lastFourIban(bankCard.iban)}
|
||||||
|
pdfChecked={attachBankPdf}
|
||||||
|
onTogglePdf={() => setAttachBankPdf((v) => !v)}
|
||||||
|
pdfLabel="Bankkarte als PDF anhängen"
|
||||||
|
pdfDisabled={!bankCard.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{identityDocument && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title={identityTypeLabel(identityDocument.type)}
|
||||||
|
preview={previewIdentity(identityDocument)}
|
||||||
|
textChecked={insertIdentityText}
|
||||||
|
onToggleText={() => setInsertIdentityText((v) => !v)}
|
||||||
|
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
|
||||||
|
textDisabled={!identityDocument.documentNumber}
|
||||||
|
pdfChecked={attachIdentityPdf}
|
||||||
|
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
|
||||||
|
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
|
||||||
|
pdfDisabled={!identityDocument.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Falls weder Customer noch Address etc. da sind */}
|
||||||
|
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
Keine weiteren Daten am Kunden hinterlegt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Text wird ans Ende der Nachricht angehängt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={busy || isLoading || nothingSelected}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Einfügen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Einfügen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI-Helper ====================
|
||||||
|
|
||||||
|
interface SectionRowProps {
|
||||||
|
title: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
preview: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DualChoiceRowProps {
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
textChecked: boolean;
|
||||||
|
onToggleText: () => void;
|
||||||
|
textLabel: string;
|
||||||
|
textDisabled?: boolean;
|
||||||
|
pdfChecked: boolean;
|
||||||
|
onTogglePdf: () => void;
|
||||||
|
pdfLabel: string;
|
||||||
|
pdfDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
|
||||||
|
* Keine primäre Checkbox – beide Schalter wirken einzeln, deshalb
|
||||||
|
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
|
||||||
|
*/
|
||||||
|
function DualChoiceRow({
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
textChecked,
|
||||||
|
onToggleText,
|
||||||
|
textLabel,
|
||||||
|
textDisabled,
|
||||||
|
pdfChecked,
|
||||||
|
onTogglePdf,
|
||||||
|
pdfLabel,
|
||||||
|
pdfDisabled,
|
||||||
|
}: DualChoiceRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{preview}</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textChecked}
|
||||||
|
onChange={onToggleText}
|
||||||
|
disabled={textDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{textLabel}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pdfChecked}
|
||||||
|
onChange={onTogglePdf}
|
||||||
|
disabled={pdfDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{pdfLabel}
|
||||||
|
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="mt-1 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Text-Block-Formatierung ====================
|
||||||
|
|
||||||
|
function fullName(
|
||||||
|
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
|
||||||
|
contractType: string,
|
||||||
|
): string {
|
||||||
|
if (contractType === 'BUSINESS' && customer.companyName) {
|
||||||
|
return customer.companyName;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (customer.salutation) parts.push(customer.salutation);
|
||||||
|
parts.push(customer.firstName);
|
||||||
|
parts.push(customer.lastName);
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
|
||||||
|
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
|
||||||
|
// niemanden – relevant ist nur, was der Anbieter selbst vergeben hat
|
||||||
|
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
|
||||||
|
// die internen Nummern komplett aus.
|
||||||
|
function formatCustomerBlock(
|
||||||
|
customer: NonNullable<Contract['customer']>,
|
||||||
|
contract: Contract,
|
||||||
|
email: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = ['Kundendaten:'];
|
||||||
|
lines.push(fullName(customer, contract.type));
|
||||||
|
if (contract.customerNumberAtProvider) {
|
||||||
|
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
|
||||||
|
}
|
||||||
|
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
|
||||||
|
if (email) lines.push(`E-Mail: ${email}`);
|
||||||
|
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
|
||||||
|
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
|
||||||
|
return [
|
||||||
|
fullName(customer, contract.type),
|
||||||
|
contract.customerNumberAtProvider
|
||||||
|
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddressBlock(label: string, addr: Address): string {
|
||||||
|
const lines: string[] = [`${label}:`];
|
||||||
|
lines.push(`${addr.street} ${addr.houseNumber}`);
|
||||||
|
lines.push(`${addr.postalCode} ${addr.city}`);
|
||||||
|
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
|
||||||
|
lines.push(addr.country);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAddress(addr: Address): string {
|
||||||
|
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
|
||||||
|
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
|
||||||
|
// vergeben hat. Vertriebsplattform-Nummern bleiben drin – die nutzt der
|
||||||
|
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
|
||||||
|
function formatContractBlock(c: Contract): string {
|
||||||
|
const lines: string[] = ['Vertragsdaten:'];
|
||||||
|
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
|
||||||
|
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
|
||||||
|
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
|
||||||
|
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
|
||||||
|
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
|
||||||
|
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
|
||||||
|
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
|
||||||
|
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
|
||||||
|
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewContract(c: Contract): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (c.contractNumberAtProvider) {
|
||||||
|
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
|
||||||
|
} else if (c.provider?.name) {
|
||||||
|
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
|
||||||
|
}
|
||||||
|
if (c.provider?.name) parts.push(c.provider.name);
|
||||||
|
if (c.tariff?.name) parts.push(c.tariff.name);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
|
||||||
|
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
|
||||||
|
// versenden ist sowieso heikel – der Empfänger kann sich mit den letzten
|
||||||
|
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
|
||||||
|
// IBAN im Mail-Verlauf hängenbleibt.
|
||||||
|
function lastFourIban(iban: string | undefined | null): string {
|
||||||
|
if (!iban) return '';
|
||||||
|
return iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBankBlock(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
if (!last4) return '';
|
||||||
|
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewBank(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityTypeLabel(type: IdentityDocument['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PASSPORT': return 'Reisepass';
|
||||||
|
case 'DRIVERS_LICENSE': return 'Führerschein';
|
||||||
|
case 'OTHER': return 'Ausweisdokument';
|
||||||
|
case 'ID_CARD':
|
||||||
|
default: return 'Personalausweis';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
|
||||||
|
// Behörde / Daten – wenn der Empfänger mehr Details braucht, soll er
|
||||||
|
// die beigefügte PDF benutzen.
|
||||||
|
function formatIdentityBlock(d: IdentityDocument): string {
|
||||||
|
if (!d.documentNumber) return '';
|
||||||
|
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewIdentity(d: IdentityDocument): string {
|
||||||
|
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Hilfs-Funktionen für ComposeEmailModal und die zwei neuen Modals
|
||||||
|
// (Vertragsdokumente anhängen, Kundendaten einfügen).
|
||||||
|
|
||||||
|
import { fileUrl } from '../../utils/fileUrl';
|
||||||
|
import type { EmailAttachment } from '../../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Server-Datei (per fileUrl mit Token) und gibt sie als
|
||||||
|
* EmailAttachment zurück. Wird sowohl für ContractDocuments als auch
|
||||||
|
* für BankCard- und IdentityDocument-PDFs benutzt.
|
||||||
|
*
|
||||||
|
* Wirft mit aussagekräftiger Message, wenn der Download fehlschlägt –
|
||||||
|
* der Caller fängt das ab und zeigt einen Toast.
|
||||||
|
*/
|
||||||
|
export async function serverFileToAttachment(
|
||||||
|
documentPath: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<EmailAttachment> {
|
||||||
|
const url = fileUrl(documentPath);
|
||||||
|
if (!url) throw new Error(`Datei "${filename}" hat keinen Pfad.`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Download von "${filename}" fehlgeschlagen (HTTP ${response.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const base64 = await blobToBase64(blob);
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
content: base64,
|
||||||
|
contentType: blob.type || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
// data:application/pdf;base64,XYZ → XYZ
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(',')[1] ?? '';
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('FileReader-Fehler'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamtgröße aller Anhänge berechnen (in Bytes, näherungsweise).
|
||||||
|
* Base64 ist ~33% größer als die Original-Bytes.
|
||||||
|
*/
|
||||||
|
export function totalAttachmentBytes(attachments: EmailAttachment[]): number {
|
||||||
|
return attachments.reduce(
|
||||||
|
(sum, att) => sum + Math.ceil(att.content.length * 0.75),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für eine Bankkarte – mit IBAN-Suffix damit beim
|
||||||
|
* Empfänger klar ist, welches Konto gemeint ist.
|
||||||
|
*/
|
||||||
|
export function bankCardAttachmentName(iban: string | undefined): string {
|
||||||
|
if (!iban) return 'Bankkarte.pdf';
|
||||||
|
const lastFour = iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
return `Bankkarte-${lastFour}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für Ausweis-PDF abhängig vom Typ.
|
||||||
|
*/
|
||||||
|
export function identityDocAttachmentName(
|
||||||
|
type: string,
|
||||||
|
documentNumber: string | undefined,
|
||||||
|
): string {
|
||||||
|
const base = type === 'PASSPORT'
|
||||||
|
? 'Reisepass'
|
||||||
|
: type === 'DRIVERS_LICENSE'
|
||||||
|
? 'Fuehrerschein'
|
||||||
|
: type === 'OTHER'
|
||||||
|
? 'Ausweisdokument'
|
||||||
|
: 'Personalausweis';
|
||||||
|
return documentNumber ? `${base}-${documentNumber}.pdf` : `${base}.pdf`;
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ interface JpgToPdfModalProps {
|
|||||||
const MAX_IMAGES = 50;
|
const MAX_IMAGES = 50;
|
||||||
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
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() {
|
function makeId() {
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
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 {
|
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -123,13 +148,22 @@ export default function JpgToPdfModal({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const dataUrl = await readFileAsDataUrl(file);
|
const rawDataUrl = await readFileAsDataUrl(file);
|
||||||
const img = await loadImage(dataUrl);
|
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({
|
added.push({
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
dataUrl,
|
dataUrl,
|
||||||
naturalWidth: img.naturalWidth,
|
naturalWidth: finalW,
|
||||||
naturalHeight: img.naturalHeight,
|
naturalHeight: finalH,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
flipH: false,
|
flipH: false,
|
||||||
flipV: false,
|
flipV: false,
|
||||||
@@ -271,7 +305,7 @@ export default function JpgToPdfModal({
|
|||||||
} else {
|
} else {
|
||||||
const img = await loadImage(item.dataUrl);
|
const img = await loadImage(item.dataUrl);
|
||||||
const canvas = renderImageToCanvas(img, item);
|
const canvas = renderImageToCanvas(img, item);
|
||||||
imageData = canvas.toDataURL('image/jpeg', 0.95);
|
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
|
||||||
imageFormat = 'JPEG';
|
imageFormat = 'JPEG';
|
||||||
srcW = canvas.width;
|
srcW = canvas.width;
|
||||||
srcH = canvas.height;
|
srcH = canvas.height;
|
||||||
|
|||||||
@@ -1838,6 +1838,16 @@ export default function ContractDetail() {
|
|||||||
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
||||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${c.customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCustomerInfo(true)}
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
@@ -2080,6 +2090,15 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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 && (
|
{c.customerNumberAtSalesPlatform && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
|
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import Card from '../../components/ui/Card';
|
|||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
|
import CopyButton from '../../components/ui/CopyButton';
|
||||||
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
@@ -220,6 +222,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
// Passwort-Sichtbarkeit
|
// Passwort-Sichtbarkeit
|
||||||
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
||||||
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
||||||
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
||||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||||
@@ -302,6 +305,7 @@ export default function ContractForm() {
|
|||||||
tariffName: c.tariffName || '',
|
tariffName: c.tariffName || '',
|
||||||
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
||||||
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
||||||
|
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
|
||||||
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
||||||
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
||||||
priceFirst12Months: c.priceFirst12Months || '',
|
priceFirst12Months: c.priceFirst12Months || '',
|
||||||
@@ -558,6 +562,7 @@ export default function ContractForm() {
|
|||||||
tariffName: emptyToNull(data.tariffName),
|
tariffName: emptyToNull(data.tariffName),
|
||||||
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
||||||
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
||||||
|
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
|
||||||
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
||||||
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
||||||
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
||||||
@@ -772,7 +777,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -780,6 +785,36 @@ export default function ContractForm() {
|
|||||||
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{customer && (
|
||||||
|
<p className="text-gray-500 ml-12 mb-6 flex items-center gap-1">
|
||||||
|
Kunde:{' '}
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customer.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
|
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
|
||||||
|
aria-label="Kundendaten anzeigen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
@@ -954,10 +989,11 @@ export default function ContractForm() {
|
|||||||
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
disabled={!selectedProviderId}
|
disabled={!selectedProviderId}
|
||||||
/>
|
/>
|
||||||
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
|
||||||
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
|
||||||
<Input label="Kundennummer bei Vertriebsplattform" {...register('customerNumberAtSalesPlatform')} />
|
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
|
||||||
<Input label="Vertragsnummer bei Vertriebsplattform" {...register('contractNumberAtSalesPlatform')} />
|
<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="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 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" />
|
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
||||||
@@ -1014,7 +1050,24 @@ export default function ContractForm() {
|
|||||||
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -1032,6 +1085,7 @@ export default function ContractForm() {
|
|||||||
{usernameType === 'manual' && (
|
{usernameType === 'manual' && (
|
||||||
<Input
|
<Input
|
||||||
{...register('portalUsername')}
|
{...register('portalUsername')}
|
||||||
|
maxLength={100}
|
||||||
placeholder="Benutzername eingeben..."
|
placeholder="Benutzername eingeben..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1742,6 +1796,15 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
{/* Status-Info Modal */}
|
{/* Status-Info Modal */}
|
||||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
|
|
||||||
|
{/* Kunden-Schnellansicht */}
|
||||||
|
{customer && (
|
||||||
|
<CustomerInfoModal
|
||||||
|
customerId={customer.id}
|
||||||
|
open={showCustomerInfo}
|
||||||
|
onClose={() => setShowCustomerInfo(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2679,10 +2679,12 @@ function BankCardModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when bankCard prop changes
|
// Re-Init nur beim Wechsel zur anderen Karte – nicht bei jedem
|
||||||
if (isEditing && formData.iban !== bankCard.iban) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bankCard?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Bankkarte bearbeiten' : 'Bankkarte hinzufügen'}>
|
||||||
@@ -2829,10 +2831,12 @@ function DocumentModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when document prop changes
|
// Re-Init nur beim Wechsel zum anderen Ausweis – nicht bei jedem
|
||||||
if (isEditing && formData.documentNumber !== document.documentNumber) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [document?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
||||||
@@ -3039,10 +3043,12 @@ function MeterModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when meter prop changes
|
// Re-Init nur beim Wechsel zum anderen Zähler – nicht bei jedem
|
||||||
if (isEditing && formData.meterNumber !== meter.meterNumber) {
|
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [meter?.id]);
|
||||||
|
|
||||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||||
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
||||||
@@ -3276,10 +3282,12 @@ function MeterReadingModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Update form when reading prop changes
|
// Re-Init nur beim Wechsel zum anderen Zählerstand – nicht bei
|
||||||
if (isEditing && formData.value !== reading.value.toString()) {
|
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||||
|
useEffect(() => {
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [reading?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
||||||
@@ -3736,6 +3744,8 @@ function StressfreiEmailModal({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||||
|
const [showForwardsModal, setShowForwardsModal] = useState(false);
|
||||||
|
const [additionalForwards, setAdditionalForwards] = useState<string[]>([]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEditing = !!email;
|
const isEditing = !!email;
|
||||||
|
|
||||||
@@ -3874,6 +3884,15 @@ function StressfreiEmailModal({
|
|||||||
setNotes(email.notes || '');
|
setNotes(email.notes || '');
|
||||||
setProviderStatus('idle');
|
setProviderStatus('idle');
|
||||||
setMailboxEnabled(email.hasMailbox || false);
|
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
|
// Status beim Provider prüfen wenn Provider vorhanden
|
||||||
if (hasProvider) {
|
if (hasProvider) {
|
||||||
checkProviderStatus(emailLocalPart);
|
checkProviderStatus(emailLocalPart);
|
||||||
@@ -3887,6 +3906,7 @@ function StressfreiEmailModal({
|
|||||||
setCreateMailbox(false);
|
setCreateMailbox(false);
|
||||||
setProviderStatus('idle');
|
setProviderStatus('idle');
|
||||||
setMailboxEnabled(false);
|
setMailboxEnabled(false);
|
||||||
|
setAdditionalForwards([]);
|
||||||
}
|
}
|
||||||
setProvisionError(null);
|
setProvisionError(null);
|
||||||
// Zugangsdaten zurücksetzen
|
// Zugangsdaten zurücksetzen
|
||||||
@@ -3898,12 +3918,25 @@ function StressfreiEmailModal({
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
mutationFn: async (data: { email: string; notes?: string; provision?: boolean; createMailbox?: boolean }) => {
|
||||||
// Verwendet die neue API-Funktion, die Provisioning und Mailbox-Erstellung unterstützt
|
// 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,
|
email: data.email,
|
||||||
notes: data.notes,
|
notes: data.notes,
|
||||||
provisionAtProvider: data.provision,
|
provisionAtProvider: data.provision,
|
||||||
createMailbox: data.createMailbox,
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
@@ -3912,6 +3945,7 @@ function StressfreiEmailModal({
|
|||||||
setNotes('');
|
setNotes('');
|
||||||
setProvisionAtProvider(false);
|
setProvisionAtProvider(false);
|
||||||
setCreateMailbox(false);
|
setCreateMailbox(false);
|
||||||
|
setAdditionalForwards([]);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -3926,6 +3960,9 @@ function StressfreiEmailModal({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setProvisionError(err instanceof Error ? err.message : 'Fehler beim Speichern');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -4167,15 +4204,243 @@ function StressfreiEmailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-between items-center gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={onClose}>
|
<div>
|
||||||
Abbrechen
|
{((isEditing && email && providerStatus === 'exists') ||
|
||||||
</Button>
|
(!isEditing && provisionAtProvider)) && (
|
||||||
<Button type="submit" disabled={isPending || !localPart}>
|
<Button
|
||||||
{isPending ? 'Speichern...' : 'Speichern'}
|
type="button"
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,13 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
||||||
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
||||||
@@ -316,6 +323,13 @@ function ProviderModal({
|
|||||||
portalUrl: provider.portalUrl || '',
|
portalUrl: provider.portalUrl || '',
|
||||||
usernameFieldName: provider.usernameFieldName || '',
|
usernameFieldName: provider.usernameFieldName || '',
|
||||||
passwordFieldName: provider.passwordFieldName || '',
|
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,
|
isActive: provider.isActive,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
@@ -325,6 +339,13 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
@@ -434,6 +455,75 @@ function ProviderModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{provider && (
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isProvisioned?: boolean;
|
isProvisioned?: boolean;
|
||||||
hasMailbox: boolean;
|
hasMailbox: boolean;
|
||||||
|
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
|
||||||
|
additionalForwardingEmails?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
|
|||||||
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
||||||
return res.data;
|
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
|
// E-Mails synchronisieren
|
||||||
syncEmails: async (id: number, fullSync = false) => {
|
syncEmails: async (id: number, fullSync = false) => {
|
||||||
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
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 });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mails für Vertrag abrufen
|
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
|
||||||
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
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 });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
|
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
|
||||||
getContractFolderCounts: async (contractId: number) => {
|
getContractFolderCounts: async (contractId: number, accountId?: number) => {
|
||||||
const res = await api.get<ApiResponse<{
|
const res = await api.get<ApiResponse<{
|
||||||
inbox: number;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
}>>(`/contracts/${contractId}/emails/folder-counts`);
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
|
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Mailbox-Konten eines Kunden abrufen
|
// Mailbox-Konten eines Kunden abrufen
|
||||||
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// ==================== PAPIERKORB ====================
|
// ==================== PAPIERKORB ====================
|
||||||
// Papierkorb-E-Mails für Kunden abrufen
|
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
|
||||||
getTrash: async (customerId: number) => {
|
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Papierkorb-Anzahl für Kunden
|
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
|
||||||
getTrashCount: async (customerId: number) => {
|
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
|
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mail aus Papierkorb wiederherstellen
|
// E-Mail aus Papierkorb wiederherstellen
|
||||||
|
|||||||
@@ -396,6 +396,13 @@ export interface Provider {
|
|||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: string;
|
passwordFieldName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
contactFax?: string;
|
||||||
|
contactAddress?: string;
|
||||||
|
cancellationEmail?: string;
|
||||||
|
cancellationFax?: string;
|
||||||
|
cancellationAddress?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
tariffs?: Tariff[];
|
tariffs?: Tariff[];
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -454,6 +461,7 @@ export interface Contract {
|
|||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
contractNumberAtProvider?: string;
|
contractNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
customerNumberAtSalesPlatform?: string;
|
customerNumberAtSalesPlatform?: string;
|
||||||
contractNumberAtSalesPlatform?: string;
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user