diff --git a/backend/src/controllers/stressfreiEmail.controller.ts b/backend/src/controllers/stressfreiEmail.controller.ts index bf5c3299..93182e7f 100644 --- a/backend/src/controllers/stressfreiEmail.controller.ts +++ b/backend/src/controllers/stressfreiEmail.controller.ts @@ -103,6 +103,41 @@ export async function deleteEmail(req: Request, res: Response): Promise { } } +export async function syncForwarding(req: AuthRequest, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + if (!(await canAccessStressfreiEmail(req, res, emailId))) return; + + const result = await stressfreiEmailService.syncForwardingForEmail(emailId); + 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: `Weiterleitungen synchronisiert: ${(result.forwardTargets || []).join(', ')}`, + }); + + res.json({ + success: true, + data: { + forwardTargets: result.forwardTargets, + customerEmail: result.customerEmail, + }, + message: 'Weiterleitungen aktualisiert', + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen', + } as ApiResponse); + } +} + export async function resetPassword(req: Request, res: Response): Promise { try { const result = await stressfreiEmailService.resetMailboxPassword(parseInt(req.params.id)); diff --git a/backend/src/routes/stressfreiEmail.routes.ts b/backend/src/routes/stressfreiEmail.routes.ts index c0e39670..f1dc24e8 100644 --- a/backend/src/routes/stressfreiEmail.routes.ts +++ b/backend/src/routes/stressfreiEmail.routes.ts @@ -12,4 +12,7 @@ router.delete('/:id', authenticate, requirePermission('customers:delete'), stres // Passwort zurücksetzen (generiert neues Passwort und setzt es beim Provider) router.post('/:id/reset-password', authenticate, requirePermission('customers:update'), stressfreiEmailController.resetPassword); +// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail) +router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding); + export default router; diff --git a/backend/src/services/emailProvider/emailProviderService.ts b/backend/src/services/emailProvider/emailProviderService.ts index 0ed2c4be..8cf8fc40 100644 --- a/backend/src/services/emailProvider/emailProviderService.ts +++ b/backend/src/services/emailProvider/emailProviderService.ts @@ -469,6 +469,22 @@ export async function deprovisionEmail(localPart: string): Promise { + try { + const provider = await getProviderInstance(); + return provider.updateForwardTargets(localPart, targets); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + return { success: false, error: errorMessage }; + } +} + // E-Mail umbenennen export async function renameProvisionedEmail( oldLocalPart: string, diff --git a/backend/src/services/stressfreiEmail.service.ts b/backend/src/services/stressfreiEmail.service.ts index d02a8e73..0b285c21 100644 --- a/backend/src/services/stressfreiEmail.service.ts +++ b/backend/src/services/stressfreiEmail.service.ts @@ -7,6 +7,8 @@ import { checkEmailExists, getProviderDomain, updateMailboxPassword, + setEmailForwardTargets, + getActiveProviderConfig, } from './emailProvider/emailProviderService.js'; import { generateSecurePassword } from '../utils/passwordGenerator.js'; @@ -251,6 +253,59 @@ export async function getDecryptedPassword(id: number): Promise { } } +// Weiterleitungen einer Stressfrei-Adresse neu setzen (z.B. nach Änderung der +// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch +// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config]. +// +// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein +// Duplikat-Risiko bei Mehrfachaufruf. +export async function syncForwardingForEmail( + id: number, +): Promise<{ + success: boolean; + forwardTargets?: string[]; + customerEmail?: string; + error?: string; +}> { + const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ + where: { id }, + select: { email: true, customerId: true, isProvisioned: true }, + }); + + if (!stressfreiEmail) { + return { success: false, error: 'StressfreiEmail nicht gefunden' }; + } + if (!stressfreiEmail.isProvisioned) { + return { success: false, error: 'E-Mail ist nicht beim Provider angelegt – Sync nicht möglich' }; + } + + const customer = await prisma.customer.findUnique({ + where: { id: stressfreiEmail.customerId }, + select: { email: true }, + }); + if (!customer?.email) { + return { success: false, error: 'Kunde hat keine Stamm-E-Mail-Adresse hinterlegt' }; + } + + const config = await getActiveProviderConfig(); + const forwardTargets: string[] = [customer.email]; + if (config?.defaultForwardEmail) { + forwardTargets.push(config.defaultForwardEmail); + } + + const localPart = stressfreiEmail.email.split('@')[0]; + const result = await setEmailForwardTargets(localPart, forwardTargets); + if (!result.success) { + return { success: false, error: result.error || 'Provider-Update fehlgeschlagen' }; + } + + return { + success: true, + forwardTargets, + customerEmail: customer.email, + }; +} + // Passwort neu generieren und beim Provider setzen export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> { const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ diff --git a/docs/todo.md b/docs/todo.md index a79beaa6..e45d4c2f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,20 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔁 Stressfrei-Adressen: Weiterleitungen manuell synchronisieren** + - Refresh-Icon-Button in der Action-Reihe jeder provisionierten + Stressfrei-Adresse (Tooltip erklärt: „ersetzt die Forwards am Provider + durch Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach + Änderung der Stamm-E-Mail eines Kunden, oder nach Wechsel der + `defaultForwardEmail` in den Provider-Settings. + - Backend nutzt das bestehende Plesk `updateForwardTargets` + (`set:email1,email2` → ersetzt komplett, idempotent). + - Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`, + `customers:update`-Permission, Audit-Log mit Forward-Targets. + - In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes + Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden + öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind. + - [x] **🛡️ Pentest-Hardening-Runde 11: Header-Hygiene** - **HSTS-Doppel-Header** (18× low im Audit): Helmet's `Strict-Transport-Security` komplett deaktiviert. Der Nginx Proxy Manager diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 10150782..a2cab3f7 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -13,7 +13,7 @@ import Modal from '../../components/ui/Modal'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; import FileUpload from '../../components/ui/FileUpload'; -import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake } from 'lucide-react'; +import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake, RefreshCw, ExternalLink } from 'lucide-react'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; import BirthdayManagementModal from '../../components/BirthdayManagementModal'; import { formatDate } from '../../utils/dateFormat'; @@ -353,6 +353,17 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? {c.email} + {(c.stressfreiEmails?.length ?? 0) > 0 && ( + + + + )} )} @@ -2964,6 +2975,26 @@ function StressfreiEmailsTab({ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customer', customerId] }), }); + // Weiterleitungen am Provider neu setzen (Stamm-Email-Wechsel-Use-Case). + const syncForwardingMutation = useMutation({ + mutationFn: stressfreiEmailApi.syncForwarding, + onSuccess: (res) => { + const targets = res?.data?.forwardTargets || []; + alert( + targets.length > 0 + ? `Weiterleitungen aktualisiert:\n${targets.map((t) => `• ${t}`).join('\n')}` + : 'Weiterleitungen aktualisiert.', + ); + queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); + }, + onError: (err: any) => { + alert( + 'Fehler beim Aktualisieren der Weiterleitungen:\n' + + (err?.response?.data?.error || err?.message || 'Unbekannter Fehler'), + ); + }, + }); + const filtered = showInactive ? emails : emails.filter((e) => e.isActive); return ( @@ -3023,6 +3054,30 @@ function StressfreiEmailsTab({ > + {emailItem.isProvisioned && ( + + )} {emailItem.isActive ? (