Files
opencrm/backend/src/services/stressfreiEmail.service.ts
T
duffyduck 51eb12b414 fix(stressfrei): Refresh-Button nur bei provisioned + Auto-Heilung im Status-Sync
User-Feedback: der Refresh-Button war auch bei nicht-provisionierten
Adressen sichtbar (die nur als DB-Eintrag ohne Plesk-Pendant existieren).
Klick darauf gab korrekt einen Fehler, war aber unschön.

Bedingung wieder auf `emailItem.isProvisioned` einschränken. Für
historische Einträge, bei denen das Flag wegen des alten Bugs nie
gesetzt wurde, gibt es jetzt einen automatischen Reconcile-Pfad:

`syncMailboxStatus` (wird beim Öffnen jedes Edit-Modals aufgerufen)
prüft nicht mehr nur `hasMailbox`, sondern auch `isProvisioned`:
- Provider antwortet "existiert" + DB sagt isProvisioned=false
  → DB-Flag auf true ziehen + provisionedAt setzen
- Provider antwortet "nicht da" + DB sagt isProvisioned=true
  → DB-Flag auf false (Adresse wurde im Plesk-UI manuell gelöscht)
- hasMailbox wird zusätzlich konsistent gehalten

Damit heilen sich falsch markierte Adressen automatisch, sobald der
User sie einmal aufmacht zum Bearbeiten – der Refresh-Button erscheint
dann beim Re-Open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:11:47 +02:00

435 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import prisma from '../lib/prisma.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import {
provisionEmail,
provisionEmailWithMailbox,
enableMailboxForExistingEmail,
checkEmailExists,
getProviderDomain,
updateMailboxPassword,
setEmailForwardTargets,
getActiveProviderConfig,
} from './emailProvider/emailProviderService.js';
import { generateSecurePassword } from '../utils/passwordGenerator.js';
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
const where: Record<string, unknown> = { customerId };
if (!includeInactive) {
where.isActive = true;
}
return prisma.stressfreiEmail.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
// Mit Mailbox-Status für E-Mail-Client
export async function getEmailsWithMailboxByCustomerId(customerId: number) {
return prisma.stressfreiEmail.findMany({
where: {
customerId,
isActive: true,
hasMailbox: true,
},
select: {
id: true,
email: true,
notes: true,
hasMailbox: true,
_count: {
select: {
cachedEmails: true,
},
},
},
orderBy: { email: 'asc' },
});
}
export async function getEmailById(id: number) {
return prisma.stressfreiEmail.findUnique({
where: { id },
});
}
// E-Mail mit Mailbox-Status laden
export async function getEmailWithMailboxById(id: number) {
return prisma.stressfreiEmail.findUnique({
where: { id },
select: {
id: true,
customerId: true,
email: true,
platform: true,
notes: true,
isActive: true,
hasMailbox: true,
emailPasswordEncrypted: true,
createdAt: true,
updatedAt: true,
},
});
}
export interface CreateEmailData {
customerId: number;
email: string;
platform?: string;
notes?: string;
provisionAtProvider?: boolean;
createMailbox?: boolean;
}
export async function createEmail(data: CreateEmailData) {
const { provisionAtProvider, createMailbox, ...emailData } = data;
// Falls beim Provider anlegen gewünscht
if (provisionAtProvider) {
// Kunde laden für Weiterleitung
const customer = await prisma.customer.findUnique({
where: { id: data.customerId },
select: { email: true },
});
if (!customer?.email) {
throw new Error('Kunde hat keine E-Mail-Adresse für Weiterleitung');
}
// LocalPart extrahieren
const localPart = data.email.split('@')[0];
if (createMailbox) {
// Mit echter Mailbox anlegen
const password = generateSecurePassword();
const result = await provisionEmailWithMailbox(localPart, customer.email, password);
if (!result.success) {
throw new Error(result.error || 'Fehler beim Anlegen der Mailbox');
}
// Passwort verschlüsseln und speichern
const passwordEncrypted = encrypt(password);
return prisma.stressfreiEmail.create({
data: {
...emailData,
isActive: true,
hasMailbox: true,
isProvisioned: true,
provisionedAt: new Date(),
emailPasswordEncrypted: passwordEncrypted,
},
});
} else {
// Nur Weiterleitung anlegen
const result = await provisionEmail(localPart, customer.email);
if (!result.success && !result.message?.includes('existiert bereits')) {
throw new Error(result.error || 'Fehler beim Anlegen der E-Mail');
}
}
}
return prisma.stressfreiEmail.create({
data: {
...emailData,
isActive: true,
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,
},
});
}
export async function updateEmail(
id: number,
data: {
email?: string;
platform?: string;
notes?: string;
isActive?: boolean;
}
) {
return prisma.stressfreiEmail.update({
where: { id },
data,
});
}
export async function deleteEmail(id: number) {
return prisma.stressfreiEmail.delete({ where: { 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({
where: { id },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
if (stressfreiEmail.hasMailbox) {
return { success: false, error: 'Mailbox ist bereits aktiviert' };
}
const localPart = stressfreiEmail.email.split('@')[0];
const password = generateSecurePassword();
// Mailbox für existierende E-Mail aktivieren (nicht neu erstellen!)
const result = await enableMailboxForExistingEmail(localPart, password);
if (!result.success) {
return { success: false, error: result.error || 'Fehler beim Aktivieren der Mailbox' };
}
// Passwort verschlüsseln und speichern
const passwordEncrypted = encrypt(password);
await prisma.stressfreiEmail.update({
where: { id },
data: {
hasMailbox: true,
emailPasswordEncrypted: passwordEncrypted,
},
});
return { success: true };
}
// Mailbox-Status mit Provider synchronisieren
export async function syncMailboxStatus(id: number): Promise<{
success: boolean;
hasMailbox?: boolean;
wasUpdated?: boolean;
error?: string
}> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { email: true, hasMailbox: true, isProvisioned: true, provisionedAt: true },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
const localPart = stressfreiEmail.email.split('@')[0];
// Provider-Status prüfen
const providerStatus = await checkEmailExists(localPart);
// Self-Healing für `isProvisioned`: das Flag wurde in einer früheren Code-
// Version beim Provisioning nie gesetzt → DB ist stellenweise inkonsistent
// zum Provider. Wir reconciliieren bei jedem Status-Sync mit.
const updates: Record<string, unknown> = {};
if (!providerStatus.exists) {
// Beim Provider nicht (mehr) vorhanden → DB-Flag entsprechend
if (stressfreiEmail.isProvisioned) {
updates.isProvisioned = false;
}
if (stressfreiEmail.hasMailbox) {
updates.hasMailbox = false;
}
if (Object.keys(updates).length > 0) {
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
return { success: true, hasMailbox: false, wasUpdated: true };
}
return { success: true, hasMailbox: false, wasUpdated: false };
}
// Beim Provider vorhanden → isProvisioned auf true ziehen falls noch nicht
if (!stressfreiEmail.isProvisioned) {
updates.isProvisioned = true;
if (!stressfreiEmail.provisionedAt) {
updates.provisionedAt = new Date();
}
}
const providerHasMailbox = providerStatus.hasMailbox === true;
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
updates.hasMailbox = providerHasMailbox;
}
if (Object.keys(updates).length > 0) {
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
console.log(`Stressfrei-Status für ${stressfreiEmail.email} reconciled:`, updates);
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
}
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: false };
}
// Passwort für IMAP/SMTP-Zugang entschlüsseln (nur für autorisierte Nutzung)
export async function getDecryptedPassword(id: number): Promise<string | null> {
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id },
select: { emailPasswordEncrypted: true },
});
if (!stressfreiEmail?.emailPasswordEncrypted) {
return null;
}
try {
return decrypt(stressfreiEmail.emailPasswordEncrypted);
} catch {
console.error('Fehler beim Entschlüsseln des Passworts');
return null;
}
}
// 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].
//
// 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. 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,
hasMailbox: true,
emailPasswordEncrypted: true,
},
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
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];
// 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,
};
}
// 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({
where: { id },
select: { email: true, hasMailbox: true },
});
if (!stressfreiEmail) {
return { success: false, error: 'StressfreiEmail nicht gefunden' };
}
if (!stressfreiEmail.hasMailbox) {
return { success: false, error: 'Keine Mailbox für diese E-Mail-Adresse' };
}
// Neues Passwort generieren
const newPassword = generateSecurePassword();
const localPart = stressfreiEmail.email.split('@')[0];
// Passwort beim Provider ändern
const providerResult = await updateMailboxPassword(localPart, newPassword);
if (!providerResult.success) {
return { success: false, error: providerResult.error || 'Fehler beim Aktualisieren des Passworts beim Provider' };
}
// Passwort verschlüsseln und lokal speichern
const passwordEncrypted = encrypt(newPassword);
await prisma.stressfreiEmail.update({
where: { id },
data: { emailPasswordEncrypted: passwordEncrypted },
});
return { success: true, password: newPassword };
}