From 36beac98c9b0ff684a8ca1534ce2989ce5b2b2af Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 18 Jun 2026 10:58:14 +0200 Subject: [PATCH] =?UTF-8?q?Stressfrei-Adressen:=20zus=C3=A4tzliche=20Weite?= =?UTF-8?q?rleitungsziele?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro StressfreiEmail können jetzt weitere Weiterleitungs-Adressen gepflegt werden, die zusätzlich zur Stamm-E-Mail des Kunden und zur globalen Default-Forward-Adresse an den Provider gepusht werden. - Schema: StressfreiEmail.additionalForwardingEmails (TEXT/JSON- Array), Migration mit IF NOT EXISTS. - syncForwardingForEmail liest die Zusatzliste mit und filtert Duplikate gegen customer.email + config.defaultForwardEmail (case-insensitive) raus. - Neuer Endpoint PUT /api/stressfrei-emails/:id/additional-forwards mit Body { emails: string[] } – ersetzt die Liste komplett und syncht den Provider direkt nach. Hard-Cap 20 Adressen, Format- Validation per Regex, Audit-Log. - Frontend: Button "Weitere Weiterleitungen" im Edit-Modus des StressfreiEmailModals (erscheint sobald die Adresse beim Provider vorhanden ist). Sub-Modal mit Liste + Add/Remove, Änderungen gehen sofort live. --- .../migration.sql | 7 + backend/prisma/schema.prisma | 6 + .../controllers/stressfreiEmail.controller.ts | 49 +++++ backend/src/routes/stressfreiEmail.routes.ts | 4 + .../src/services/stressfreiEmail.service.ts | 74 +++++++ docs/todo.md | 15 ++ .../src/pages/customers/CustomerDetail.tsx | 206 +++++++++++++++++- frontend/src/services/api.ts | 10 + 8 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 backend/prisma/migrations/20260608100000_stressfrei_email_additional_forwards/migration.sql diff --git a/backend/prisma/migrations/20260608100000_stressfrei_email_additional_forwards/migration.sql b/backend/prisma/migrations/20260608100000_stressfrei_email_additional_forwards/migration.sql new file mode 100644 index 00000000..b2f08d19 --- /dev/null +++ b/backend/prisma/migrations/20260608100000_stressfrei_email_additional_forwards/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0756bfc7..10b65a12 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -402,6 +402,12 @@ model StressfreiEmail { hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)? emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM) + // Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden + // hinaus). Wird beim Sync zusammen mit customer.email + + // config.defaultForwardEmail an den Provider geschickt. JSON-Array + // von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`. + additionalForwardingEmails String? @db.Text + contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox createdAt DateTime @default(now()) diff --git a/backend/src/controllers/stressfreiEmail.controller.ts b/backend/src/controllers/stressfreiEmail.controller.ts index cb6faa31..1b4c8c32 100644 --- a/backend/src/controllers/stressfreiEmail.controller.ts +++ b/backend/src/controllers/stressfreiEmail.controller.ts @@ -3,6 +3,7 @@ import * as stressfreiEmailService from '../services/stressfreiEmail.service.js' import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js'; +import { ApiError } from '../utils/apiError.js'; export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise { try { @@ -151,6 +152,54 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + 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 { try { const emailId = parseInt(req.params.id); diff --git a/backend/src/routes/stressfreiEmail.routes.ts b/backend/src/routes/stressfreiEmail.routes.ts index f1dc24e8..f3ea3636 100644 --- a/backend/src/routes/stressfreiEmail.routes.ts +++ b/backend/src/routes/stressfreiEmail.routes.ts @@ -15,4 +15,8 @@ router.post('/:id/reset-password', authenticate, requirePermission('customers:up // Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail) router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding); +// Zusätzliche Weiterleitungs-Ziele setzen (User-pflegbare Liste, zusätzlich +// zur Stamm-E-Mail des Kunden und der globalen Default-Forward-Adresse). +router.put('/:id/additional-forwards', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateAdditionalForwards); + export default router; diff --git a/backend/src/services/stressfreiEmail.service.ts b/backend/src/services/stressfreiEmail.service.ts index f9c6ec7d..48bb8fa6 100644 --- a/backend/src/services/stressfreiEmail.service.ts +++ b/backend/src/services/stressfreiEmail.service.ts @@ -11,6 +11,41 @@ import { getActiveProviderConfig, } from './emailProvider/emailProviderService.js'; import { generateSecurePassword } from '../utils/passwordGenerator.js'; +import { ApiError } from '../utils/apiError.js'; + +// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces, +// Komma). Wirklich validiert wird vom Provider beim Sync. +const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/; + +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); +} + +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}`); + } + return trimmed.toLowerCase(); +} export async function getEmailsByCustomerId(customerId: number, includeInactive = false) { const where: Record = { customerId }; @@ -163,6 +198,36 @@ export async function deleteEmail(id: number) { return prisma.stressfreiEmail.delete({ where: { id } }); } +/** + * Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und + * direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical + * Liste – das Sub-Modal arbeitet auf Snapshot-Basis. + */ +export async function setAdditionalForwards( + id: number, + emails: string[], +): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> { + // Input normalisieren + Duplikate raus (case-insensitive). + const seen = new Set(); + const cleaned: string[] = []; + for (const raw of emails) { + const ok = assertValidForwardingEmail(raw); + if (!seen.has(ok)) { + seen.add(ok); + cleaned.push(ok); + } + } + + await prisma.stressfreiEmail.update({ + where: { id }, + data: { additionalForwardingEmails: serializeAdditionalForwards(cleaned) }, + }); + + // Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto + // mit der alten Liste weiter. + return syncForwardingForEmail(id); +} + // Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung) export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> { const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ @@ -313,6 +378,7 @@ export async function syncForwardingForEmail( isProvisioned: true, hasMailbox: true, emailPasswordEncrypted: true, + additionalForwardingEmails: true, }, }); @@ -333,6 +399,14 @@ export async function syncForwardingForEmail( if (config?.defaultForwardEmail) { forwardTargets.push(config.defaultForwardEmail); } + // Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate + // gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit + // Plesk nicht mit Wiederholungen die Liste aufbläht. + for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) { + if (!forwardTargets.some((t) => t.toLowerCase() === extra.toLowerCase())) { + forwardTargets.push(extra); + } + } const localPart = stressfreiEmail.email.split('@')[0]; diff --git a/docs/todo.md b/docs/todo.md index d4324030..388c6017 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,21 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [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())` diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 07a6c970..7b0c76ef 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { pushHistory, popHistory } from '../../utils/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -3744,6 +3744,7 @@ function StressfreiEmailModal({ } | null>(null); const [isLoadingCredentials, setIsLoadingCredentials] = useState(false); const [isResettingPassword, setIsResettingPassword] = useState(false); + const [showForwardsModal, setShowForwardsModal] = useState(false); const queryClient = useQueryClient(); const isEditing = !!email; @@ -4175,15 +4176,204 @@ function StressfreiEmailModal({ )} -
- - +
+
+ {isEditing && email && providerStatus === 'exists' && ( + + )} +
+
+ + +
+ + {isEditing && email && ( + setShowForwardsModal(false)} + email={email} + customerEmail={customerEmail} + /> + )} + + ); +} + +// Untermodal: zusätzliche Weiterleitungs-E-Mails verwalten +function AdditionalForwardsModal({ + isOpen, + onClose, + email, + customerEmail, +}: { + isOpen: boolean; + onClose: () => void; + email: StressfreiEmail; + customerEmail?: string; +}) { + const queryClient = useQueryClient(); + const initial = useMemo(() => { + if (!email.additionalForwardingEmails) return []; + try { + const parsed = JSON.parse(email.additionalForwardingEmails); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } + }, [email.additionalForwardingEmails]); + + const [forwards, setForwards] = useState(initial); + const [newEmail, setNewEmail] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setForwards(initial); + setNewEmail(''); + setError(null); + }, [initial, isOpen]); + + const EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/; + + const persist = async (next: string[]) => { + setIsSubmitting(true); + setError(null); + try { + await stressfreiEmailApi.updateAdditionalForwards(email.id, next); + setForwards(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); + } + }; + + 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; + } + if (candidate === customerEmail?.toLowerCase()) { + setError('Die Stamm-E-Mail des Kunden ist bereits Weiterleitungsziel.'); + return; + } + if (forwards.some((f) => f.toLowerCase() === candidate)) { + setError('Diese Adresse ist schon in der Liste.'); + return; + } + try { + await persist([...forwards, candidate]); + setNewEmail(''); + } catch { + /* error wird oben gesetzt */ + } + }; + + const handleRemove = async (target: string) => { + try { + await persist(forwards.filter((f) => f !== target)); + } catch { + /* error wird oben gesetzt */ + } + }; + + return ( + +
+

+ Posteingänge gehen immer an die Stamm-E-Mail des Kunden + {customerEmail && ( + <> ({customerEmail}) + )} + . Hier kannst du zusätzliche Adressen hinterlegen, die ebenfalls eine Kopie bekommen. Änderungen werden sofort am E-Mail-Provider übernommen. +

+ +
+ + {forwards.length === 0 ? ( +

Noch keine zusätzlichen Adressen.

+ ) : ( +
    + {forwards.map((f) => ( +
  • + {f} + +
  • + ))} +
+ )} +
+ +
+ +
+ { + 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} + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ff04c551..f545e341 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -369,6 +369,8 @@ export interface StressfreiEmail { isActive: boolean; isProvisioned?: boolean; hasMailbox: boolean; + /** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */ + additionalForwardingEmails?: string | null; createdAt: string; updatedAt: string; } @@ -537,6 +539,14 @@ export const stressfreiEmailApi = { }>>(`/stressfrei-emails/${id}/sync-forwarding`); return res.data; }, + // Zusätzliche Weiterleitungs-Adressen ersetzen + sofort am Provider syncen. + updateAdditionalForwards: async (id: number, emails: string[]) => { + const res = await api.put>( + `/stressfrei-emails/${id}/additional-forwards`, + { emails }, + ); + return res.data; + }, // E-Mails synchronisieren syncEmails: async (id: number, fullSync = false) => { const res = await api.post>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });