diff --git a/backend/src/controllers/stressfreiEmail.controller.ts b/backend/src/controllers/stressfreiEmail.controller.ts index 93182e7f..11b53188 100644 --- a/backend/src/controllers/stressfreiEmail.controller.ts +++ b/backend/src/controllers/stressfreiEmail.controller.ts @@ -114,12 +114,15 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise { // Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch // [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config]. // +// Wenn die Adresse `hasMailbox` ist: setzt zusätzlich das im CRM verschlüsselt +// hinterlegte Passwort am Provider neu (Use-Case: Plesk-Restore, manueller +// Eingriff im Plesk-UI etc. – CRM und Provider können sich entkoppeln, sodass +// IMAP/SMTP-Logins im CRM nicht mehr passen). Self-Healing. +// // Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein -// Duplikat-Risiko bei Mehrfachaufruf. +// Duplikat-Risiko bei Mehrfachaufruf. Wenn die Operation erfolgreich war wird +// das `isProvisioned`-Flag automatisch auf `true` gezogen (historische +// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt). export async function syncForwardingForEmail( id: number, ): Promise<{ success: boolean; forwardTargets?: string[]; customerEmail?: string; + passwordReset?: boolean; error?: string; }> { const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id }, - select: { email: true, customerId: true, isProvisioned: true }, + select: { + email: true, + customerId: true, + isProvisioned: true, + hasMailbox: true, + emailPasswordEncrypted: 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 }, @@ -294,15 +312,65 @@ export async function syncForwardingForEmail( } const localPart = stressfreiEmail.email.split('@')[0]; - const result = await setEmailForwardTargets(localPart, forwardTargets); - if (!result.success) { - return { success: false, error: result.error || 'Provider-Update fehlgeschlagen' }; + + // 1) Forwards neu setzen. + const forwardResult = await setEmailForwardTargets(localPart, forwardTargets); + if (!forwardResult.success) { + // Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung + // statt der rohen Provider-Nachricht. + const err = forwardResult.error || 'Provider-Update fehlgeschlagen'; + const friendly = /not\s*found|nicht\s*gefunden/i.test(err) + ? 'E-Mail-Adresse beim Provider nicht gefunden – wurde sie dort gelöscht?' + : err; + return { success: false, error: friendly }; + } + + // 2) Wenn Mailbox: Passwort aus CRM-Speicher entschlüsseln und am Provider + // neu setzen (Self-Healing nach Provider-seitigen Änderungen). + let passwordReset = false; + if (stressfreiEmail.hasMailbox && stressfreiEmail.emailPasswordEncrypted) { + try { + const password = decrypt(stressfreiEmail.emailPasswordEncrypted); + const pwResult = await updateMailboxPassword(localPart, password); + if (!pwResult.success) { + // Forwards waren schon erfolgreich – wir geben Forward-Erfolg + Passwort- + // Fehler kombiniert zurück, statt die ganze Operation rot zu machen. + return { + success: false, + forwardTargets, + customerEmail: customer.email, + error: + 'Weiterleitungen aktualisiert, aber Passwort-Sync fehlgeschlagen: ' + + (pwResult.error || 'unbekannt'), + }; + } + passwordReset = true; + } catch (e) { + return { + success: false, + forwardTargets, + customerEmail: customer.email, + error: + 'Weiterleitungen aktualisiert, aber Passwort konnte nicht entschlüsselt werden – ' + + 'evtl. wurde der ENCRYPTION_KEY rotiert', + }; + } + } + + // 3) Self-Healing: nach erfolgreichem Provider-Aufruf wissen wir definitiv, + // dass die Adresse beim Provider existiert → Flag korrigieren. + if (!stressfreiEmail.isProvisioned) { + await prisma.stressfreiEmail.update({ + where: { id }, + data: { isProvisioned: true, provisionedAt: new Date() }, + }); } return { success: true, forwardTargets, customerEmail: customer.email, + passwordReset, }; } diff --git a/docs/todo.md b/docs/todo.md index e45d4c2f..8d18fbc2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,16 +97,29 @@ 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 +- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren** + - Refresh-Icon-Button in der Action-Reihe jeder 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). + - **Bei `hasMailbox: true`** wird zusätzlich das im CRM verschlüsselt + hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing + für den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort + gesetzt hat und IMAP/SMTP im CRM nicht mehr passt. + - Backend nutzt Plesk's `updateForwardTargets` (`set:email1,email2` + → ersetzt komplett, idempotent) + bei Mailbox auch + `updateMailboxPassword` (Plesk-Passwort-Update). - Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`, - `customers:update`-Permission, Audit-Log mit Forward-Targets. + `customers:update`-Permission, Audit-Log mit Forward-Targets + + Passwort-Reset-Marker. + - Self-Healing: `isProvisioned`-Flag wird bei erfolgreichem + Provider-Aufruf automatisch auf `true` korrigiert (historischer Bug: + Flag wurde beim `createEmail` mit `provisionAtProvider: true` nie + gesetzt – jetzt behoben + Backfill via Sync). + - Erfolgs-/Fehler-Meldungen via `react-hot-toast` (statt `alert()`) + mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset + durchgeführt wurde. - 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. diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index a2cab3f7..0fcd89c4 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } 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'; +import toast from 'react-hot-toast'; import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api'; import { EmailClientTab } from '../../components/email'; import { useAuth } from '../../context/AuthContext'; @@ -2976,21 +2977,26 @@ function StressfreiEmailsTab({ }); // Weiterleitungen am Provider neu setzen (Stamm-Email-Wechsel-Use-Case). + // Wenn die Adresse hasMailbox=true ist, wird zusätzlich das im CRM + // hinterlegte Passwort am Provider neu gesetzt (Self-Healing nach + // manuellen Eingriffen am Provider). 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.', - ); + const passwordReset = res?.data?.passwordReset; + const lines = [ + 'Weiterleitungen aktualisiert:', + ...targets.map((t) => `• ${t}`), + ]; + if (passwordReset) lines.push('Mailbox-Passwort am Provider neu gesetzt.'); + toast.success(lines.join('\n'), { duration: 5000 }); queryClient.invalidateQueries({ queryKey: ['customer', customerId] }); }, onError: (err: any) => { - alert( - 'Fehler beim Aktualisieren der Weiterleitungen:\n' + - (err?.response?.data?.error || err?.message || 'Unbekannter Fehler'), + toast.error( + err?.response?.data?.error || err?.message || 'Fehler beim Aktualisieren der Weiterleitungen', + { duration: 6000 }, ); }, }); @@ -3054,30 +3060,38 @@ function StressfreiEmailsTab({ > - {emailItem.isProvisioned && ( - - )} + {emailItem.isActive ? (