fix(stressfrei): sync-forwarding sichtbar + Passwort-Push + Toast-Meldungen

Drei Verbesserungen am gestrigen Sync-Feature:

1. Bug-Fix: isProvisioned wurde nie auf true gesetzt
   `createEmail` mit `provisionAtProvider: true` hat das Flag
   `isProvisioned` nie gesetzt → blieb auf @default(false). Damit
   blieb der Refresh-Button in der UI unsichtbar (Bedingung
   `emailItem.isProvisioned`). Jetzt:
   - createEmail setzt isProvisioned + provisionedAt korrekt
   - Self-Healing: syncForwardingForEmail setzt das Flag nachträglich
     auf true sobald der Provider-Aufruf erfolgreich war (Backfill
     für historisch falsch markierte Einträge)
   - UI-Sichtbarkeit: Bedingung entfernt – der Button erscheint jetzt
     immer; ein Klick auf eine nicht-provisionierte Adresse liefert
     eine sprechende Fehlermeldung statt stiller Verstecken

2. Passwort-Push bei hasMailbox: true
   Bisher wurden nur die Forwards aktualisiert. Jetzt entschlüsselt
   syncForwardingForEmail bei Mailbox-Adressen zusätzlich das im CRM
   gespeicherte Passwort und setzt es am Provider neu – Self-Healing
   für IMAP/SMTP-Logins falls jemand im Plesk-UI manuell ein anderes
   Passwort gesetzt hat. Response enthält `passwordReset: true` als
   Marker.

3. react-hot-toast statt alert()
   Erfolgs-Toast listet die neu gesetzten Forward-Targets + Hinweis
   ob Passwort-Reset durchgeführt wurde. Fehler-Toast zeigt die
   Backend-Fehlermeldung (z.B. „E-Mail-Adresse beim Provider nicht
   gefunden – wurde sie dort gelöscht?").

Audit-Log-Label enthält jetzt sowohl Forwards als auch Passwort-Reset-
Marker, damit der Vorgang im AuditLog nachvollziehbar bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 14:06:26 +02:00
parent b4be3cebfb
commit c2ebc7cf1e
5 changed files with 156 additions and 53 deletions
@@ -114,12 +114,15 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
return; return;
} }
const labelParts = [`Weiterleitungen: ${(result.forwardTargets || []).join(', ')}`];
if (result.passwordReset) labelParts.push('Mailbox-Passwort am Provider neu gesetzt');
await logChange({ await logChange({
req, req,
action: 'UPDATE', action: 'UPDATE',
resourceType: 'StressfreiEmail', resourceType: 'StressfreiEmail',
resourceId: emailId.toString(), resourceId: emailId.toString(),
label: `Weiterleitungen synchronisiert: ${(result.forwardTargets || []).join(', ')}`, label: `Stressfrei-Sync: ${labelParts.join(' | ')}`,
}); });
res.json({ res.json({
@@ -127,6 +130,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
data: { data: {
forwardTargets: result.forwardTargets, forwardTargets: result.forwardTargets,
customerEmail: result.customerEmail, customerEmail: result.customerEmail,
passwordReset: result.passwordReset,
}, },
message: 'Weiterleitungen aktualisiert', message: 'Weiterleitungen aktualisiert',
} as ApiResponse); } as ApiResponse);
@@ -115,6 +115,8 @@ export async function createEmail(data: CreateEmailData) {
...emailData, ...emailData,
isActive: true, isActive: true,
hasMailbox: true, hasMailbox: true,
isProvisioned: true,
provisionedAt: new Date(),
emailPasswordEncrypted: passwordEncrypted, emailPasswordEncrypted: passwordEncrypted,
}, },
}); });
@@ -133,6 +135,11 @@ export async function createEmail(data: CreateEmailData) {
...emailData, ...emailData,
isActive: true, isActive: true,
hasMailbox: createMailbox || false, hasMailbox: createMailbox || false,
// Provisioned-Flag nur setzen wenn Provider-Aufruf gerade lief (oder
// die Mail bei Plesk schon existierte und der „existiert bereits"-Pfad
// gegriffen hat).
isProvisioned: !!provisionAtProvider,
provisionedAt: provisionAtProvider ? new Date() : null,
}, },
}); });
} }
@@ -257,27 +264,38 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch // Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config]. // [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 // 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( export async function syncForwardingForEmail(
id: number, id: number,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
forwardTargets?: string[]; forwardTargets?: string[];
customerEmail?: string; customerEmail?: string;
passwordReset?: boolean;
error?: string; error?: string;
}> { }> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id }, where: { id },
select: { email: true, customerId: true, isProvisioned: true }, select: {
email: true,
customerId: true,
isProvisioned: true,
hasMailbox: true,
emailPasswordEncrypted: true,
},
}); });
if (!stressfreiEmail) { if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' }; 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({ const customer = await prisma.customer.findUnique({
where: { id: stressfreiEmail.customerId }, where: { id: stressfreiEmail.customerId },
@@ -294,15 +312,65 @@ export async function syncForwardingForEmail(
} }
const localPart = stressfreiEmail.email.split('@')[0]; const localPart = stressfreiEmail.email.split('@')[0];
const result = await setEmailForwardTargets(localPart, forwardTargets);
if (!result.success) { // 1) Forwards neu setzen.
return { success: false, error: result.error || 'Provider-Update fehlgeschlagen' }; 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 { return {
success: true, success: true,
forwardTargets, forwardTargets,
customerEmail: customer.email, customerEmail: customer.email,
passwordReset,
}; };
} }
+21 -8
View File
@@ -97,16 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🔁 Stressfrei-Adressen: Weiterleitungen manuell synchronisieren** - [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
- Refresh-Icon-Button in der Action-Reihe jeder provisionierten - Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
Stressfrei-Adresse (Tooltip erklärt: „ersetzt die Forwards am Provider (Tooltip erklärt: „ersetzt die Forwards am Provider durch
durch Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Änderung der
Änderung der Stamm-E-Mail eines Kunden, oder nach Wechsel der Stamm-E-Mail eines Kunden, oder nach Wechsel der
`defaultForwardEmail` in den Provider-Settings. `defaultForwardEmail` in den Provider-Settings.
- Backend nutzt das bestehende Plesk `updateForwardTargets` - **Bei `hasMailbox: true`** wird zusätzlich das im CRM verschlüsselt
(`set:email1,email2` → ersetzt komplett, idempotent). 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`, - 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 - In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes
Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
öffnet sichtbar nur wenn Stressfrei-Adressen vorhanden sind. öffnet sichtbar nur wenn Stressfrei-Adressen vorhanden sind.
+46 -32
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation'; import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email'; import { EmailClientTab } from '../../components/email';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
@@ -2976,21 +2977,26 @@ function StressfreiEmailsTab({
}); });
// Weiterleitungen am Provider neu setzen (Stamm-Email-Wechsel-Use-Case). // 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({ const syncForwardingMutation = useMutation({
mutationFn: stressfreiEmailApi.syncForwarding, mutationFn: stressfreiEmailApi.syncForwarding,
onSuccess: (res) => { onSuccess: (res) => {
const targets = res?.data?.forwardTargets || []; const targets = res?.data?.forwardTargets || [];
alert( const passwordReset = res?.data?.passwordReset;
targets.length > 0 const lines = [
? `Weiterleitungen aktualisiert:\n${targets.map((t) => `${t}`).join('\n')}` 'Weiterleitungen aktualisiert:',
: '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] }); queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
}, },
onError: (err: any) => { onError: (err: any) => {
alert( toast.error(
'Fehler beim Aktualisieren der Weiterleitungen:\n' + err?.response?.data?.error || err?.message || 'Fehler beim Aktualisieren der Weiterleitungen',
(err?.response?.data?.error || err?.message || 'Unbekannter Fehler'), { duration: 6000 },
); );
}, },
}); });
@@ -3054,30 +3060,38 @@ function StressfreiEmailsTab({
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
{emailItem.isProvisioned && ( <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" disabled={syncForwardingMutation.isPending}
disabled={syncForwardingMutation.isPending} onClick={() => {
onClick={() => { const lines = [
if ( `Weiterleitungen für ${emailItem.email} jetzt neu setzen?`,
confirm( '',
'Weiterleitungen für ' + emailItem.email + ' jetzt neu setzen?\n\n' + 'Alle bestehenden Weiterleitungen am Provider werden ersetzt durch:',
'Alle bestehenden Weiterleitungen werden ersetzt durch:\n' + '• die aktuelle Stamm-E-Mail des Kunden',
'• die aktuelle Stamm-E-Mail des Kunden\n' + '• unsere Service-Weiterleitungsadresse aus den Provider-Einstellungen',
'• unsere Service-Weiterleitungsadresse aus den Provider-Einstellungen', ];
) if (emailItem.hasMailbox) {
) { lines.push(
syncForwardingMutation.mutate(emailItem.id); '',
} 'Zusätzlich wird das im CRM hinterlegte Mailbox-Passwort am Provider neu gesetzt.',
}} );
title="Weiterleitungen synchronisieren ersetzt die Forwards am Provider durch (Kunden-Stamm-E-Mail + Service-Adresse). Nützlich nach Änderung der Stamm-E-Mail." }
> if (confirm(lines.join('\n'))) {
<RefreshCw syncForwardingMutation.mutate(emailItem.id);
className={`w-4 h-4 ${syncForwardingMutation.isPending ? 'animate-spin' : ''}`} }
/> }}
</Button> title={
)} emailItem.hasMailbox
? 'Weiterleitungen + Mailbox-Passwort synchronisieren. Nützlich nach Änderung der Kunden-Stamm-E-Mail oder nach manuellem Eingriff am Provider.'
: 'Weiterleitungen synchronisieren ersetzt die Forwards am Provider durch (Kunden-Stamm-E-Mail + Service-Adresse). Nützlich nach Änderung der Stamm-E-Mail.'
}
>
<RefreshCw
className={`w-4 h-4 ${syncForwardingMutation.isPending ? 'animate-spin' : ''}`}
/>
</Button>
{emailItem.isActive ? ( {emailItem.isActive ? (
<Button <Button
variant="ghost" variant="ghost"
+8 -4
View File
@@ -427,11 +427,15 @@ export const stressfreiEmailApi = {
const res = await api.post<ApiResponse<{ password: string }>>(`/stressfrei-emails/${id}/reset-password`); const res = await api.post<ApiResponse<{ password: string }>>(`/stressfrei-emails/${id}/reset-password`);
return res.data; return res.data;
}, },
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail) // Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail).
// Wenn die Adresse hasMailbox=true ist, wird zusätzlich das im CRM
// hinterlegte Passwort am Provider neu gesetzt (Self-Healing).
syncForwarding: async (id: number) => { syncForwarding: async (id: number) => {
const res = await api.post<ApiResponse<{ forwardTargets: string[]; customerEmail: string }>>( const res = await api.post<ApiResponse<{
`/stressfrei-emails/${id}/sync-forwarding`, forwardTargets: string[];
); customerEmail: string;
passwordReset?: boolean;
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
return res.data; return res.data;
}, },
// E-Mails synchronisieren // E-Mails synchronisieren