From 5293af18a518865b266a2a6a578f1e751137cca9 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 21 Jun 2026 15:55:13 +0200 Subject: [PATCH] =?UTF-8?q?E-Mail-Compose:=20Vertragsdokumente=20anh=C3=A4?= =?UTF-8?q?ngen=20+=20Kundendaten=20einf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei neue Buttons im Compose-Modal (nur sichtbar bei Vertrag- Kontext): - Vertragsdokumente: listet alle am Vertrag gespeicherten ContractDocuments gruppiert nach documentType. Auswahl → Token-Download via fileUrl → base64 → Anhang. - Kundendaten einfügen: zeigt Sections nur wenn Daten vorhanden (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag, Bank, Ausweis). Bei Bank/Ausweis zusätzlich Sub-Checkbox "als PDF anhängen" wenn documentPath vorhanden. Text-Blöcke ans Body- Ende, PDFs in attachments[]. 25-MB-Limit beidseitig geprüft. Helpers in composeAttachmentHelpers.ts: - serverFileToAttachment(path, filename) für Token-URL→Blob→base64 - totalAttachmentBytes mit ~33% base64-Overhead - sprechende Dateinamen via bankCardAttachmentName / identityDocAttachmentName Co-Authored-By: Claude Opus 4.7 --- docs/todo.md | 25 + .../email/AttachContractDocumentsModal.tsx | 186 +++++++ .../components/email/ComposeEmailModal.tsx | 76 ++- .../email/InsertCustomerDataModal.tsx | 464 ++++++++++++++++++ .../email/composeAttachmentHelpers.ts | 86 ++++ 5 files changed, 827 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/email/AttachContractDocumentsModal.tsx create mode 100644 frontend/src/components/email/InsertCustomerDataModal.tsx create mode 100644 frontend/src/components/email/composeAttachmentHelpers.ts diff --git a/docs/todo.md b/docs/todo.md index 961e29ea..9a80bcae 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,31 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [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. diff --git a/frontend/src/components/email/AttachContractDocumentsModal.tsx b/frontend/src/components/email/AttachContractDocumentsModal.tsx new file mode 100644 index 00000000..74221839 --- /dev/null +++ b/frontend/src/components/email/AttachContractDocumentsModal.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { FileText, 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 { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers'; + +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>(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>((acc, doc) => { + const key = doc.documentType || 'Sonstiges'; + if (!acc[key]) acc[key] = []; + acc[key].push(doc); + return acc; + }, {}); + + return ( + +
+ {isLoading ? ( +
+ + Dokumente werden geladen… +
+ ) : documents.length === 0 ? ( +
+ +

Keine Dokumente am Vertrag hinterlegt

+
+ ) : ( +
+ {Object.entries(grouped).map(([type, docs]) => ( +
+
+ {type} +
+
+ {docs.map((doc) => ( + + ))} +
+
+ ))} +
+ )} + +
+ + {selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/email/ComposeEmailModal.tsx b/frontend/src/components/email/ComposeEmailModal.tsx index c62811e2..0caa6558 100644 --- a/frontend/src/components/email/ComposeEmailModal.tsx +++ b/frontend/src/components/email/ComposeEmailModal.tsx @@ -1,10 +1,12 @@ import { useState, useRef, useEffect } from 'react'; -import { Send, Paperclip, X, FileText } from 'lucide-react'; +import { Send, Paperclip, X, FileText, FilePlus, UserPlus } from 'lucide-react'; import toast from 'react-hot-toast'; import Modal from '../ui/Modal'; import Button from '../ui/Button'; import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api'; import { useMutation } from '@tanstack/react-query'; +import AttachContractDocumentsModal from './AttachContractDocumentsModal'; +import InsertCustomerDataModal from './InsertCustomerDataModal'; interface ComposeEmailModalProps { isOpen: boolean; @@ -31,6 +33,8 @@ export default function ComposeEmailModal({ const [body, setBody] = useState(''); const [attachments, setAttachments] = useState([]); const [error, setError] = useState(null); + const [showAttachDocsModal, setShowAttachDocsModal] = useState(false); + const [showInsertDataModal, setShowInsertDataModal] = useState(false); const fileInputRef = useRef(null); // Formular bei Modal-Öffnung initialisieren @@ -308,15 +312,39 @@ export default function ComposeEmailModal({ className="hidden" /> - {/* Anhang hinzufügen Button */} - + {/* Anhang-/Daten-Buttons */} +
+ + {contractId && ( + <> + + + + )} +
{/* Anhang-Liste */} {attachments.length > 0 && ( @@ -374,6 +402,34 @@ export default function ComposeEmailModal({ + + {/* Sub-Modal: Vertragsdokumente anhängen */} + {contractId && ( + setShowAttachDocsModal(false)} + contractId={contractId} + currentAttachments={attachments} + onAttach={(added) => setAttachments((prev) => [...prev, ...added])} + /> + )} + + {/* Sub-Modal: Kundendaten einfügen */} + {contractId && ( + setShowInsertDataModal(false)} + contractId={contractId} + currentBody={body} + currentAttachments={attachments} + onResult={(newBody, addedAtt) => { + setBody(newBody); + if (addedAtt.length > 0) { + setAttachments((prev) => [...prev, ...addedAtt]); + } + }} + /> + )} ); } diff --git a/frontend/src/components/email/InsertCustomerDataModal.tsx b/frontend/src/components/email/InsertCustomerDataModal.tsx new file mode 100644 index 00000000..cebdd915 --- /dev/null +++ b/frontend/src/components/email/InsertCustomerDataModal.tsx @@ -0,0 +1,464 @@ +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; + currentBody: string; + currentAttachments: EmailAttachment[]; + onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void; +} + +const MAX_TOTAL_SIZE = 25 * 1024 * 1024; + +type SectionKey = + | 'customer' + | 'deliveryAddress' + | 'billingAddress' + | 'contract' + | 'iban' + | 'identity'; + +export default function InsertCustomerDataModal({ + isOpen, + onClose, + contractId, + 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-Checkboxen + // bleiben default-aus (User-Intent). + const [checked, setChecked] = useState>({ + customer: true, + deliveryAddress: true, + billingAddress: false, + contract: true, + iban: false, + identity: false, + }); + const [attachBankCard, setAttachBankCard] = useState(false); + const [attachIdentity, setAttachIdentity] = useState(false); + 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, + iban: false, + identity: false, + }); + setAttachBankCard(false); + setAttachIdentity(false); + } + }, [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) { + blocks.push(formatCustomerBlock(customer, contract)); + } + 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 (checked.iban && bankCard) { + blocks.push(formatBankBlock(bankCard)); + } + if (checked.identity && 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 => { + 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 (attachBankCard && bankCard?.documentPath) { + await tryAttach( + bankCard.documentPath, + bankCardAttachmentName(bankCard.iban), + ); + } + if (attachIdentity && 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 && + !checked.iban && + !checked.identity && + !attachBankCard && + !attachIdentity; + + return ( + +
+ {isLoading || !contract ? ( +
+ + Daten werden geladen… +
+ ) : ( +
+ {customer && ( + toggle('customer')} + preview={previewCustomer(customer, contract)} + /> + )} + {deliveryAddress && ( + toggle('deliveryAddress')} + preview={previewAddress(deliveryAddress)} + /> + )} + {billingAddress && ( + toggle('billingAddress')} + preview={previewAddress(billingAddress)} + /> + )} + toggle('contract')} + preview={previewContract(contract)} + /> + {bankCard && ( + toggle('iban')} + preview={previewBank(bankCard)} + extra={ + bankCard.documentPath && ( + + ) + } + /> + )} + {identityDocument && ( + toggle('identity')} + preview={previewIdentity(identityDocument)} + extra={ + identityDocument.documentPath && ( + + ) + } + /> + )} + + {/* Falls weder Customer noch Address etc. da sind */} + {!customer && !deliveryAddress && !bankCard && !identityDocument && ( +

+ Keine weiteren Daten am Kunden hinterlegt. +

+ )} +
+ )} + +
+ + Text wird ans Ende der Nachricht angehängt. + +
+ + +
+
+
+
+ ); +} + +// ==================== UI-Helper ==================== + +interface SectionRowProps { + title: string; + checked: boolean; + onToggle: () => void; + preview: string; + extra?: React.ReactNode; +} + +function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) { + return ( +
+ + {extra} +
+ ); +} + +// ==================== 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(' '); +} + +function formatCustomerBlock(customer: NonNullable, contract: Contract): string { + const lines: string[] = ['Kundendaten:']; + lines.push(fullName(customer, contract.type)); + if (customer.customerNumber) lines.push(`Kundennummer: ${customer.customerNumber}`); + if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`); + if (customer.email) lines.push(`E-Mail: ${customer.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: Contract): string { + return [ + fullName(customer, contract.type), + customer.customerNumber ? `Kundennummer: ${customer.customerNumber}` : '', + ] + .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}`; +} + +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}`); + 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[] = [c.contractNumber]; + if (c.provider?.name) parts.push(c.provider.name); + if (c.tariff?.name) parts.push(c.tariff.name); + return parts.join(' · '); +} + +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'); +} + +function previewBank(b: BankCard): string { + return `IBAN: ${b.iban}${b.bankName ? ` · ${b.bankName}` : ''}`; +} + +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'; + } +} + +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'); +} + +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(' · '); +} diff --git a/frontend/src/components/email/composeAttachmentHelpers.ts b/frontend/src/components/email/composeAttachmentHelpers.ts new file mode 100644 index 00000000..8475afe2 --- /dev/null +++ b/frontend/src/components/email/composeAttachmentHelpers.ts @@ -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 { + 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 { + 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`; +}