Compare commits
7 Commits
5293af18a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe | |||
| a4895374b9 | |||
| ebaee024b6 | |||
| f1b05c56e5 |
@@ -392,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
|
||||
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
|
||||
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
|
||||
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
|
||||
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
|
||||
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
|
||||
const MAX_ATTACHMENT_COUNT = 25;
|
||||
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
interface AttachmentValidationError {
|
||||
status: 400;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function validateAttachments(
|
||||
attachments: unknown,
|
||||
): { ok: true } | AttachmentValidationError {
|
||||
if (attachments === undefined) return { ok: true };
|
||||
if (!Array.isArray(attachments)) {
|
||||
return { status: 400, error: 'attachments muss ein Array sein.' };
|
||||
}
|
||||
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
|
||||
};
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const a = attachments[i];
|
||||
const label = `Anhang ${i + 1}`;
|
||||
if (!a || typeof a !== 'object') {
|
||||
return { status: 400, error: `${label} hat das falsche Format.` };
|
||||
}
|
||||
const filename = (a as Record<string, unknown>).filename;
|
||||
const content = (a as Record<string, unknown>).content;
|
||||
const contentType = (a as Record<string, unknown>).contentType;
|
||||
if (typeof filename !== 'string' || filename.trim() === '') {
|
||||
return { status: 400, error: `${label} hat keinen Dateinamen.` };
|
||||
}
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
|
||||
};
|
||||
}
|
||||
if (!BASE64_RE.test(content)) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
|
||||
};
|
||||
}
|
||||
if (contentType !== undefined && typeof contentType !== 'string') {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
|
||||
};
|
||||
}
|
||||
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
|
||||
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
|
||||
const approxBytes = Math.ceil(content.length * 0.75);
|
||||
if (approxBytes > MAX_PER_FILE_BYTES) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
|
||||
};
|
||||
}
|
||||
totalBytes += approxBytes;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
return {
|
||||
status: 400,
|
||||
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -408,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
|
||||
// (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler
|
||||
// in die Response und Limits waren nur Frontend-Doku.
|
||||
const attachmentCheck = validateAttachments(attachments);
|
||||
if (!('ok' in attachmentCheck)) {
|
||||
res.status(attachmentCheck.status).json({
|
||||
success: false,
|
||||
error: attachmentCheck.error,
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail laden
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
|
||||
@@ -87,48 +87,72 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
||||
// Stored-XSS gerendert.
|
||||
//
|
||||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
|
||||
// ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
|
||||
// Magic eines bekannten safe Typs (PDF, PNG, JPEG, GIF, WebP) zeigen.
|
||||
// Bei Mismatch fällt's auf attachment zurück – Stored XSS bleibt
|
||||
// weiterhin unmöglich.
|
||||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||||
//
|
||||
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||||
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||||
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||||
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||||
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const wantsInline = req.query.disposition === 'inline';
|
||||
let useInline = false;
|
||||
let inlineContentType: string | null = null;
|
||||
if (wantsInline) {
|
||||
try {
|
||||
const fd = fs.openSync(absolute, 'r');
|
||||
const head = Buffer.alloc(12);
|
||||
fs.readSync(fd, head, 0, 12, 0);
|
||||
fs.closeSync(fd);
|
||||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||
useInline = true;
|
||||
inlineContentType = 'application/pdf';
|
||||
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
useInline = true;
|
||||
inlineContentType = 'image/png';
|
||||
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
|
||||
useInline = true;
|
||||
inlineContentType = 'image/jpeg';
|
||||
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
|
||||
useInline = true;
|
||||
inlineContentType = 'image/gif';
|
||||
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||||
useInline = true;
|
||||
inlineContentType = 'image/webp';
|
||||
}
|
||||
} catch { /* ignore – fällt auf attachment zurück */ }
|
||||
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||
|
||||
if (wantsInline && !safeContentType) {
|
||||
console.warn(
|
||||
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (useInline && inlineContentType) {
|
||||
res.setHeader('Content-Type', inlineContentType);
|
||||
if (safeContentType) {
|
||||
res.setHeader('Content-Type', safeContentType);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||||
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||||
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||||
*
|
||||
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||||
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||||
*/
|
||||
function detectSafeContentType(absolute: string): string | null {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(absolute, 'r');
|
||||
const head = Buffer.alloc(12);
|
||||
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||||
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||||
if (bytesRead >= 8
|
||||
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) return 'image/png';
|
||||
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||
if (bytesRead >= 6
|
||||
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||||
) return 'image/gif';
|
||||
if (bytesRead >= 12
|
||||
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||||
) return 'image/webp';
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
|
||||
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
|
||||
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
|
||||
`fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
|
||||
Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
|
||||
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
|
||||
aber nicht reproduzieren.
|
||||
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
|
||||
extrahiert, finally-Block schließt File-Descriptor garantiert,
|
||||
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
|
||||
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
|
||||
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
|
||||
R30.13).
|
||||
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
|
||||
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
|
||||
der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
|
||||
war's silent.
|
||||
|
||||
- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
|
||||
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
|
||||
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
|
||||
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
|
||||
durchgehen.
|
||||
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
|
||||
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
|
||||
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
|
||||
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
|
||||
läuft **vor** dem `sendEmail`-Aufruf:
|
||||
- `attachments` muss Array oder undefined sein
|
||||
- max 25 Anhänge
|
||||
- jeder: `filename` non-empty String, `content` non-empty Base64-
|
||||
String (Regex), optional `contentType` String
|
||||
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
|
||||
kein Buffer.from-Aufruf während der Validierung)
|
||||
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
|
||||
grün inkl. aller R97.1-Pentest-Payloads.
|
||||
|
||||
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||||
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||||
"Datei anhängen":
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Loader2 } from 'lucide-react';
|
||||
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;
|
||||
@@ -127,10 +128,11 @@ export default function AttachContractDocumentsModal({
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{docs.map((doc) => (
|
||||
<label
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
|
||||
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)}
|
||||
@@ -150,6 +152,18 @@ export default function AttachContractDocumentsModal({
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -420,6 +420,7 @@ export default function ComposeEmailModal({
|
||||
isOpen={showInsertDataModal}
|
||||
onClose={() => setShowInsertDataModal(false)}
|
||||
contractId={contractId}
|
||||
senderEmail={account.email}
|
||||
currentBody={body}
|
||||
currentAttachments={attachments}
|
||||
onResult={(newBody, addedAtt) => {
|
||||
|
||||
@@ -489,9 +489,13 @@ export default function ContractEmailsSection({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
||||
{/* Email List */}
|
||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
||||
<div
|
||||
className="flex -mx-6 -mb-6"
|
||||
style={{ height: '600px' }}
|
||||
>
|
||||
{/* Email List – scrollt intern, damit die Vertrags-Seite nicht
|
||||
elendig lang wird. */}
|
||||
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
|
||||
{selectedFolder === 'TRASH' ? (
|
||||
<TrashEmailList
|
||||
emails={trashEmails}
|
||||
|
||||
@@ -295,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
|
||||
// Bounded auf Viewport-Höhe – sonst ignoriert h-full ohnehin den
|
||||
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
|
||||
// ganze Seite scrollt statt nur die E-Mail-Liste.
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||
{/* Account Selector */}
|
||||
|
||||
@@ -18,25 +18,34 @@ 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'
|
||||
| 'iban'
|
||||
| 'identity';
|
||||
| 'contract';
|
||||
|
||||
export default function InsertCustomerDataModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
contractId,
|
||||
senderEmail,
|
||||
currentBody,
|
||||
currentAttachments,
|
||||
onResult,
|
||||
@@ -61,18 +70,28 @@ export default function InsertCustomerDataModal({
|
||||
const bankCard = contract?.bankCard;
|
||||
const identityDocument = contract?.identityDocument;
|
||||
|
||||
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-Checkboxen
|
||||
// bleiben default-aus (User-Intent).
|
||||
// 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,
|
||||
iban: false,
|
||||
identity: false,
|
||||
});
|
||||
const [attachBankCard, setAttachBankCard] = useState(false);
|
||||
const [attachIdentity, setAttachIdentity] = useState(false);
|
||||
// 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
|
||||
@@ -84,11 +103,13 @@ export default function InsertCustomerDataModal({
|
||||
deliveryAddress: !!deliveryAddress,
|
||||
billingAddress: false, // nur wenn vorhanden, aber default aus
|
||||
contract: true,
|
||||
iban: false,
|
||||
identity: false,
|
||||
});
|
||||
setAttachBankCard(false);
|
||||
setAttachIdentity(false);
|
||||
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]);
|
||||
|
||||
@@ -108,7 +129,13 @@ export default function InsertCustomerDataModal({
|
||||
const blocks: string[] = [];
|
||||
|
||||
if (checked.customer && customer) {
|
||||
blocks.push(formatCustomerBlock(customer, contract));
|
||||
const chosenEmail =
|
||||
emailChoice === 'master'
|
||||
? customer.email || ''
|
||||
: emailChoice === 'sender'
|
||||
? senderEmail
|
||||
: '';
|
||||
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
|
||||
}
|
||||
if (checked.deliveryAddress && deliveryAddress) {
|
||||
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
|
||||
@@ -119,10 +146,10 @@ export default function InsertCustomerDataModal({
|
||||
if (checked.contract) {
|
||||
blocks.push(formatContractBlock(contract));
|
||||
}
|
||||
if (checked.iban && bankCard) {
|
||||
if (insertBankText && bankCard) {
|
||||
blocks.push(formatBankBlock(bankCard));
|
||||
}
|
||||
if (checked.identity && identityDocument) {
|
||||
if (insertIdentityText && identityDocument) {
|
||||
blocks.push(formatIdentityBlock(identityDocument));
|
||||
}
|
||||
|
||||
@@ -155,13 +182,13 @@ export default function InsertCustomerDataModal({
|
||||
}
|
||||
};
|
||||
|
||||
if (attachBankCard && bankCard?.documentPath) {
|
||||
if (attachBankPdf && bankCard?.documentPath) {
|
||||
await tryAttach(
|
||||
bankCard.documentPath,
|
||||
bankCardAttachmentName(bankCard.iban),
|
||||
);
|
||||
}
|
||||
if (attachIdentity && identityDocument?.documentPath) {
|
||||
if (attachIdentityPdf && identityDocument?.documentPath) {
|
||||
await tryAttach(
|
||||
identityDocument.documentPath,
|
||||
identityDocAttachmentName(
|
||||
@@ -188,10 +215,10 @@ export default function InsertCustomerDataModal({
|
||||
!checked.deliveryAddress &&
|
||||
!checked.billingAddress &&
|
||||
!checked.contract &&
|
||||
!checked.iban &&
|
||||
!checked.identity &&
|
||||
!attachBankCard &&
|
||||
!attachIdentity;
|
||||
!insertBankText &&
|
||||
!attachBankPdf &&
|
||||
!insertIdentityText &&
|
||||
!attachIdentityPdf;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -214,6 +241,56 @@ export default function InsertCustomerDataModal({
|
||||
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 && (
|
||||
@@ -239,47 +316,31 @@ export default function InsertCustomerDataModal({
|
||||
preview={previewContract(contract)}
|
||||
/>
|
||||
{bankCard && (
|
||||
<SectionRow
|
||||
<DualChoiceRow
|
||||
title="Bankverbindung"
|
||||
checked={checked.iban}
|
||||
onToggle={() => toggle('iban')}
|
||||
preview={previewBank(bankCard)}
|
||||
extra={
|
||||
bankCard.documentPath && (
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 mt-1 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attachBankCard}
|
||||
onChange={(e) => setAttachBankCard(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Bankkarte als PDF anhängen</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
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 && (
|
||||
<SectionRow
|
||||
<DualChoiceRow
|
||||
title={identityTypeLabel(identityDocument.type)}
|
||||
checked={checked.identity}
|
||||
onToggle={() => toggle('identity')}
|
||||
preview={previewIdentity(identityDocument)}
|
||||
extra={
|
||||
identityDocument.documentPath && (
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 mt-1 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attachIdentity}
|
||||
onChange={(e) => setAttachIdentity(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>
|
||||
{identityTypeLabel(identityDocument.type)} als PDF anhängen
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -330,6 +391,69 @@ interface SectionRowProps {
|
||||
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">
|
||||
@@ -368,12 +492,23 @@ function fullName(
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function formatCustomerBlock(customer: NonNullable<Contract['customer']>, contract: Contract): string {
|
||||
// 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 (customer.customerNumber) lines.push(`Kundennummer: ${customer.customerNumber}`);
|
||||
if (contract.customerNumberAtProvider) {
|
||||
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
|
||||
}
|
||||
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
|
||||
if (customer.email) lines.push(`E-Mail: ${customer.email}`);
|
||||
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');
|
||||
@@ -382,7 +517,9 @@ function formatCustomerBlock(customer: NonNullable<Contract['customer']>, contra
|
||||
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
|
||||
return [
|
||||
fullName(customer, contract.type),
|
||||
customer.customerNumber ? `Kundennummer: ${customer.customerNumber}` : '',
|
||||
contract.customerNumberAtProvider
|
||||
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
@@ -402,9 +539,12 @@ 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:'];
|
||||
lines.push(`Vertragsnummer: ${c.contractNumber}`);
|
||||
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}`);
|
||||
@@ -418,23 +558,36 @@ function formatContractBlock(c: Contract): string {
|
||||
}
|
||||
|
||||
function previewContract(c: Contract): string {
|
||||
const parts: string[] = [c.contractNumber];
|
||||
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 lines: string[] = ['Bankverbindung:'];
|
||||
if (b.accountHolder) lines.push(`Kontoinhaber: ${b.accountHolder}`);
|
||||
lines.push(`IBAN: ${b.iban}`);
|
||||
if (b.bic) lines.push(`BIC: ${b.bic}`);
|
||||
if (b.bankName) lines.push(`Bank: ${b.bankName}`);
|
||||
return lines.join('\n');
|
||||
const last4 = lastFourIban(b.iban);
|
||||
if (!last4) return '';
|
||||
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
|
||||
}
|
||||
|
||||
function previewBank(b: BankCard): string {
|
||||
return `IBAN: ${b.iban}${b.bankName ? ` · ${b.bankName}` : ''}`;
|
||||
const last4 = lastFourIban(b.iban);
|
||||
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
|
||||
}
|
||||
|
||||
function identityTypeLabel(type: IdentityDocument['type']): string {
|
||||
@@ -447,18 +600,14 @@ function identityTypeLabel(type: IdentityDocument['type']): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const lines: string[] = [`${identityTypeLabel(d.type)}:`];
|
||||
if (d.documentNumber) lines.push(`Nummer: ${d.documentNumber}`);
|
||||
if (d.issuingAuthority) lines.push(`Ausstellende Behörde: ${d.issuingAuthority}`);
|
||||
if (d.issueDate) lines.push(`Ausstellungsdatum: ${formatDate(d.issueDate)}`);
|
||||
if (d.expiryDate) lines.push(`Gültig bis: ${formatDate(d.expiryDate)}`);
|
||||
return lines.join('\n');
|
||||
if (!d.documentNumber) return '';
|
||||
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
|
||||
}
|
||||
|
||||
function previewIdentity(d: IdentityDocument): string {
|
||||
const parts: string[] = [];
|
||||
if (d.documentNumber) parts.push(`Nr. ${d.documentNumber}`);
|
||||
if (d.expiryDate) parts.push(`gültig bis ${formatDate(d.expiryDate)}`);
|
||||
return parts.join(' · ');
|
||||
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user